{"version":3,"file":"discovery-dGX-ZZkK.mjs","names":[],"sources":["../src/transport/discovery.ts"],"sourcesContent":["import { randomBytes } from 'node:crypto'\nimport { access, constants, lstat, readFile, rename, unlink, writeFile } from 'node:fs/promises'\nimport { Socket } from 'node:net'\n\nimport * as v from 'valibot'\n\nimport { MCP_TOOL_SCOPES, type MCPToolScope } from '#mcp/tool/scope'\nimport { getDiscoveryPath, getSocketPath, platformHasUnixSockets } from '#mcp/transport/paths'\n\n/**\n * Metadata written to the discovery file so clients can auto-locate\n * the running MCP server without knowing the socket path or TCP port.\n *\n * WARNING: The discovery file contains a plaintext auth token. Do not sync\n * this file to cloud storage or include it in backups without encryption.\n * Any process running as the same user can read this file.\n */\nexport interface DiscoveryInfo {\n  pid: number\n  /** Unix domain socket path, or null on platforms that don't support them (Windows). */\n  socketPath: string | null\n  httpPort: number\n  authRequired: boolean\n  authToken: string | null\n  version: string\n  startedAt: string\n  disabledTools?: string[]\n  /** What MCP clients can reach; stdio clients started outside the app follow it. */\n  scope?: MCPToolScope\n}\n\nconst DiscoveryInfoJSON = v.pipe(\n  v.string(),\n  v.parseJson(),\n  v.object({\n    pid: v.pipe(v.number(), v.integer(), v.minValue(1)),\n    version: v.string(),\n    httpPort: v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)),\n    authRequired: v.boolean(),\n    startedAt: v.string(),\n    socketPath: v.nullable(v.pipe(v.string(), v.nonEmpty())),\n    authToken: v.nullable(v.string()),\n    disabledTools: v.optional(v.array(v.string()), () => []),\n    scope: v.optional(v.picklist(MCP_TOOL_SCOPES), 'document')\n  })\n) satisfies v.GenericSchema<string, DiscoveryInfo>\n\n/** Parse discovery file contents, or null when the file is not valid discovery JSON. */\nexport function parseDiscoveryInfo(raw: string): DiscoveryInfo | null {\n  const result = v.safeParse(DiscoveryInfoJSON, raw)\n  return result.success ? result.output : null\n}\n\n/**\n * Writes the discovery JSON file at the platform-appropriate location.\n * Overwrites any existing file (from a previous server instance).\n *\n * The write is atomic: a sibling temp file is written first with `0o600`\n * permissions, then renamed over the final path. This avoids leaving\n * a half-written file visible to readers if the process is killed\n * mid-write, and prevents concurrent writers from corrupting the file\n * by interleaving their writes.\n *\n * WARNING: The auth token is written in plaintext. Do not sync the discovery\n * file to cloud storage or include it in backups without encryption.\n */\nexport async function writeDiscoveryFile(info: DiscoveryInfo): Promise<void> {\n  const path = await getDiscoveryPath()\n  const json = JSON.stringify(info, null, 2)\n  // Add a random component to guarantee uniqueness\n  const random = randomBytes(6).toString('hex')\n  const tmpPath = `${path}.${process.pid}.${random}.tmp`\n  await writeFile(tmpPath, json, { mode: 0o600, encoding: 'utf-8' })\n  try {\n    await rename(tmpPath, path)\n  } catch (err) {\n    // Best-effort cleanup of the temp file on rename failure\n    await unlink(tmpPath).catch(() => undefined)\n    throw err\n  }\n}\n\n/**\n * Reads the discovery file. Returns null if:\n * - The file does not exist\n * - The file cannot be parsed\n * - The recorded PID is no longer running (stale file)\n *\n * On success, returns the parsed DiscoveryInfo.\n */\nexport async function readDiscoveryFile(): Promise<DiscoveryInfo | null> {\n  const path = await getDiscoveryPath()\n  let raw: string\n  try {\n    raw = await readFile(path, 'utf-8')\n  } catch (e) {\n    if (!isEnoent(e))\n      process.stderr.write(\n        `  Discovery: read warning (${e instanceof Error ? e.message : String(e)})\\n`\n      )\n    return null\n  }\n\n  const info = parseDiscoveryInfo(raw)\n  if (!info) return null\n  if (!isProcessAlive(info.pid)) return null\n\n  return info\n}\n\n/**\n * Removes the discovery file. Does not throw if the file does not exist.\n */\nexport async function removeDiscoveryFile(): Promise<void> {\n  const path = await getDiscoveryPath()\n  try {\n    await unlink(path)\n  } catch (e) {\n    if (!isEnoent(e)) throw e\n  }\n}\n\n// TCP health check is more reliable than Unix socket connections in some\n// runtimes (Bun on Linux). We read the discovery file directly instead of\n// using readDiscoveryFile() to bypass the isProcessAlive PID check — the\n// TCP fetch is a stronger liveness probe than process.kill(pid, 0).\nasync function isSocketLiveViaTcp(socketPath: string): Promise<boolean> {\n  let discoveryPath: string\n  try {\n    discoveryPath = await getDiscoveryPath()\n  } catch {\n    return false\n  }\n  const raw = await readFile(discoveryPath, 'utf-8').catch(() => null)\n  if (!raw) return false\n  const info = parseDiscoveryInfo(raw)\n  if (!info || info.socketPath !== socketPath) return false\n\n  // If TCP is disabled (httpPort <= 0), fall back to PID-based liveness check\n  if (info.httpPort <= 0) {\n    return isProcessAlive(info.pid)\n  }\n\n  return fetch(`http://127.0.0.1:${info.httpPort}/health`, {\n    signal: AbortSignal.timeout(2000)\n  })\n    .then(() => true)\n    .catch((e) => {\n      // A timeout means the process is likely alive but slow — don't delete its socket\n      if (e instanceof Error && e.name === 'TimeoutError') return true\n      return false\n    })\n}\n\n/**\n * Direct Unix-socket connection probe. A server binds the socket before\n * writeDiscovery(), so isSocketLiveViaTcp() can return false during that\n * window. This probe connects directly to the socket path — a successful\n * connection or timeout means the socket is live; only ECONNREFUSED means\n * no process is listening.\n */\nfunction probeSocketLive(socketPath: string): Promise<boolean> {\n  return new Promise((resolve) => {\n    let settled = false\n    const socket = new Socket()\n    const finish = (result: boolean) => {\n      if (settled) return\n      settled = true\n      clearTimeout(timer)\n      socket.destroy()\n      // oxlint-disable-next-line no-multiple-resolved -- settled flag guarantees single resolution\n      resolve(result)\n    }\n    const timer = setTimeout(() => finish(true), 1000)\n    socket.once('connect', () => finish(true))\n    socket.once('error', (err) => {\n      const code = (err as NodeJS.ErrnoException).code\n      finish(code !== 'ECONNREFUSED')\n    })\n    socket.connect(socketPath)\n  })\n}\n\n/**\n * Removes a stale Unix domain socket file if it exists and is not live.\n * A socket is considered stale if no process is listening on it.\n */\nexport async function removeStaleSocket(socketPathOverride?: string): Promise<void> {\n  if (!platformHasUnixSockets()) return\n\n  const socketPath = socketPathOverride ?? (await getSocketPath())\n  let exists: boolean\n  try {\n    await access(socketPath, constants.F_OK)\n    exists = true\n  } catch {\n    exists = false\n  }\n\n  if (!exists) return\n\n  // Verify the path is actually a socket before unlinking it.\n  // A misconfigured OPENPENCIL_MCP_SOCKET could point at a regular file;\n  // we must never delete non-socket paths.\n  const stat = await lstat(socketPath).catch((e) => {\n    if (isEnoent(e)) return null\n    throw e\n  })\n  if (!stat) return\n  if (!stat.isSocket()) {\n    throw new Error(`Refusing to remove non-socket path: ${socketPath}`)\n  }\n\n  if (await probeSocketLive(socketPath)) return\n  if (await isSocketLiveViaTcp(socketPath)) return\n\n  // No live server claims this socket — remove the stale file.\n  try {\n    await unlink(socketPath)\n  } catch (e) {\n    if (e instanceof Error && 'code' in e && (e as NodeJS.ErrnoException).code !== 'ENOENT') {\n      process.stderr.write(`Failed to remove stale socket: ${e.message}\\n`)\n    }\n  }\n}\n\n/**\n * Checks if a process with the given PID is still running.\n * Uses process.kill(pid, 0) which doesn't send a signal, just checks existence.\n * Works on both Unix and Windows.\n */\nfunction isProcessAlive(pid: number): boolean {\n  try {\n    process.kill(pid, 0)\n    return true\n  } catch (e) {\n    if (e instanceof Error && 'code' in e && (e as NodeJS.ErrnoException).code === 'ESRCH') {\n      return false\n    }\n    return true\n  }\n}\n\nfunction isEnoent(e: unknown): boolean {\n  return e instanceof Error && 'code' in e && (e as NodeJS.ErrnoException).code === 'ENOENT'\n}\n"],"mappings":"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"}