#!/usr/bin/env node import { randomUUID } from 'node:crypto' import { McpServer } from '@modelcontextprotocol/server' import { StdioServerTransport } from '@modelcontextprotocol/server/stdio' import { resolveMCPRoot } from '#mcp/root' import { MCP_VERSION, registerTools } from '#mcp/server' import { createStdioRPCBridge } from '#mcp/stdio/bridge' import type { ToolPolicy } from '#mcp/tool/metadata' import { parseDisabledTools } from '#mcp/tool/policy' import { narrowestScope, parseToolScope } from '#mcp/tool/scope' import { readDiscoveryFile } from '#mcp/transport/discovery' if (process.argv.includes('--version')) { process.stdout.write(`${MCP_VERSION}\n`) process.exit(0) } if (process.argv.includes('--help') || process.argv.includes('-h')) { process.stdout.write( `openpencil-mcp\n\n` + `Start the OpenPencil MCP stdio bridge.\n\n` + `Connects to the MCP server via Unix domain socket on macOS/Linux ` + `(with TCP fallback) or via TCP on Windows.\n` + `The MCP server is started by the OpenPencil\n` + `desktop app; this bridge only forwards stdio JSON-RPC to it.\n\n` + `Options:\n` + ` --help, -h Show this help message\n` + ` --version Print the version\n\n` + `Environment variables:\n` + ` OPENPENCIL_MCP_SOCKET Override socket path (auto-discovered from discovery file when unset)\n` + ` OPENPENCIL_MCP_AUTH_TOKEN Bearer token for RPC auth\n` + ` OPENPENCIL_MCP_ROOT Allowed directory for file-scoped tools\n` + ` (default: home directory on Windows, cwd elsewhere)\n` + ` OPENPENCIL_MCP_EVAL Set to 1 to enable the eval tool\n` + ` OPENPENCIL_MCP_DISABLED_TOOLS Comma-separated tool names to omit; defaults to the app setting\n` + ` OPENPENCIL_MCP_SCOPE "selection" to read only the selected layers; defaults to the app setting\n` ) process.exit(0) } const discovery = await readDiscoveryFile() const toolPolicy: ToolPolicy = { allowEval: process.env.OPENPENCIL_MCP_EVAL === '1', disabledTools: process.env.OPENPENCIL_MCP_DISABLED_TOOLS === undefined ? (discovery?.disabledTools ?? []) : parseDisabledTools(process.env.OPENPENCIL_MCP_DISABLED_TOOLS), // The server enforces its own scope on every call; this keeps the tool list in step with it, // and lets the client limit itself further. scope: narrowestScope( discovery?.scope ?? 'document', parseToolScope(process.env.OPENPENCIL_MCP_SCOPE) ) } const mcpRoot = resolveMCPRoot(process.env.OPENPENCIL_MCP_ROOT) // Auth token: undefined → auto-discover from discovery file, empty string → // disable auth, whitespace-only → reject (same fail-fast as index.ts to catch // misconfiguration), otherwise → use the trimmed value. const rawAuthToken = process.env.OPENPENCIL_MCP_AUTH_TOKEN if (rawAuthToken !== undefined && rawAuthToken !== '' && rawAuthToken.trim() === '') { process.stderr.write( 'Error: OPENPENCIL_MCP_AUTH_TOKEN is whitespace-only. Set a real token, or use an empty string to disable auth.\n' ) process.exit(1) } function resolveAuthToken(raw: string | undefined): string | null | undefined { if (raw === undefined) return undefined if (raw === '') return null return raw.trim() } const authToken = resolveAuthToken(rawAuthToken) // OPENPENCIL_MCP_SOCKET is intentionally NOT forwarded as an explicit socketPath. // The bridge reads the socket path from the discovery file (whose `socketPath` // field records the override) via auto-discovery. Treating the env var as an // explicit pin would prevent the bridge from following discovery updates after // a server restart. const bridge = createStdioRPCBridge({ authToken, onReady: () => { process.stderr.write( 'Connected to OpenPencil MCP server; document availability is checked per tool call\n' ) }, onReconnect: () => { process.stderr.write( 'Reconnected to OpenPencil MCP server; document availability is checked per tool call\n' ) } }) const mcpServer = new McpServer({ name: 'open-pencil', version: MCP_VERSION }) // One client per process: its tool calls show as one agent in the app. registerTools(mcpServer, { policy: toolPolicy, mcpRoot, sendRPC: bridge.sendRPC, agentSession: { id: randomUUID(), kind: 'mcp' } }) const transport = new StdioServerTransport() mcpServer.connect(transport).catch((err) => { process.stderr.write(`Fatal: MCP connect failed — ${err instanceof Error ? err.message : err}\n`) process.exit(1) }) process.on('uncaughtException', (err) => { process.stderr.write(`Fatal: ${err instanceof Error ? err.message : err}\n`) process.exit(1) }) process.on('unhandledRejection', (reason: unknown) => { const message = reason instanceof Error ? reason.message : String(reason) process.stderr.write(`Fatal: unhandled rejection — ${message}\n`) process.exit(1) })