/ utils / claudeInChrome / mcpServer.ts
mcpServer.ts
  1  import {
  2    type ClaudeForChromeContext,
  3    createClaudeForChromeMcpServer,
  4    type Logger,
  5    type PermissionMode,
  6  } from '@ant/claude-for-chrome-mcp'
  7  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
  8  import { format } from 'util'
  9  import { shutdownDatadog } from '../../services/analytics/datadog.js'
 10  import { shutdown1PEventLogging } from '../../services/analytics/firstPartyEventLogger.js'
 11  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
 12  import {
 13    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 14    logEvent,
 15  } from '../../services/analytics/index.js'
 16  import { initializeAnalyticsSink } from '../../services/analytics/sink.js'
 17  import { getClaudeAIOAuthTokens } from '../auth.js'
 18  import { enableConfigs, getGlobalConfig, saveGlobalConfig } from '../config.js'
 19  import { logForDebugging } from '../debug.js'
 20  import { isEnvTruthy } from '../envUtils.js'
 21  import { sideQuery } from '../sideQuery.js'
 22  import { getAllSocketPaths, getSecureSocketPath } from './common.js'
 23  
 24  const EXTENSION_DOWNLOAD_URL = 'https://claude.ai/chrome'
 25  const BUG_REPORT_URL =
 26    'https://github.com/anthropics/claude-code/issues/new?labels=bug,claude-in-chrome'
 27  
 28  // String metadata keys safe to forward to analytics. Keys like error_message
 29  // are excluded because they could contain page content or user data.
 30  const SAFE_BRIDGE_STRING_KEYS = new Set([
 31    'bridge_status',
 32    'error_type',
 33    'tool_name',
 34  ])
 35  
 36  const PERMISSION_MODES: readonly PermissionMode[] = [
 37    'ask',
 38    'skip_all_permission_checks',
 39    'follow_a_plan',
 40  ]
 41  
 42  function isPermissionMode(raw: string): raw is PermissionMode {
 43    return PERMISSION_MODES.some(m => m === raw)
 44  }
 45  
 46  /**
 47   * Resolves the Chrome bridge URL based on environment and feature flag.
 48   * Bridge is used when the feature flag is enabled; ant users always get
 49   * bridge. API key / 3P users fall back to native messaging.
 50   */
 51  function getChromeBridgeUrl(): string | undefined {
 52    const bridgeEnabled =
 53      process.env.USER_TYPE === 'ant' ||
 54      getFeatureValue_CACHED_MAY_BE_STALE('tengu_copper_bridge', false)
 55  
 56    if (!bridgeEnabled) {
 57      return undefined
 58    }
 59  
 60    if (
 61      isEnvTruthy(process.env.USE_LOCAL_OAUTH) ||
 62      isEnvTruthy(process.env.LOCAL_BRIDGE)
 63    ) {
 64      return 'ws://localhost:8765'
 65    }
 66  
 67    if (isEnvTruthy(process.env.USE_STAGING_OAUTH)) {
 68      return 'wss://bridge-staging.claudeusercontent.com'
 69    }
 70  
 71    return 'wss://bridge.claudeusercontent.com'
 72  }
 73  
 74  function isLocalBridge(): boolean {
 75    return (
 76      isEnvTruthy(process.env.USE_LOCAL_OAUTH) ||
 77      isEnvTruthy(process.env.LOCAL_BRIDGE)
 78    )
 79  }
 80  
 81  /**
 82   * Build the ClaudeForChromeContext used by both the subprocess MCP server
 83   * and the in-process path in the MCP client.
 84   */
 85  export function createChromeContext(
 86    env?: Record<string, string>,
 87  ): ClaudeForChromeContext {
 88    const logger = new DebugLogger()
 89    const chromeBridgeUrl = getChromeBridgeUrl()
 90    logger.info(`Bridge URL: ${chromeBridgeUrl ?? 'none (using native socket)'}`)
 91    const rawPermissionMode =
 92      env?.CLAUDE_CHROME_PERMISSION_MODE ??
 93      process.env.CLAUDE_CHROME_PERMISSION_MODE
 94    let initialPermissionMode: PermissionMode | undefined
 95    if (rawPermissionMode) {
 96      if (isPermissionMode(rawPermissionMode)) {
 97        initialPermissionMode = rawPermissionMode
 98      } else {
 99        logger.warn(
100          `Invalid CLAUDE_CHROME_PERMISSION_MODE "${rawPermissionMode}". Valid values: ${PERMISSION_MODES.join(', ')}`,
101        )
102      }
103    }
104    return {
105      serverName: 'Claude in Chrome',
106      logger,
107      socketPath: getSecureSocketPath(),
108      getSocketPaths: getAllSocketPaths,
109      clientTypeId: 'claude-code',
110      onAuthenticationError: () => {
111        logger.warn(
112          'Authentication error occurred. Please ensure you are logged into the Claude browser extension with the same claude.ai account as Claude Code.',
113        )
114      },
115      onToolCallDisconnected: () => {
116        return `Browser extension is not connected. Please ensure the Claude browser extension is installed and running (${EXTENSION_DOWNLOAD_URL}), and that you are logged into claude.ai with the same account as Claude Code. If this is your first time connecting to Chrome, you may need to restart Chrome for the installation to take effect. If you continue to experience issues, please report a bug: ${BUG_REPORT_URL}`
117      },
118      onExtensionPaired: (deviceId: string, name: string) => {
119        saveGlobalConfig(config => {
120          if (
121            config.chromeExtension?.pairedDeviceId === deviceId &&
122            config.chromeExtension?.pairedDeviceName === name
123          ) {
124            return config
125          }
126          return {
127            ...config,
128            chromeExtension: {
129              pairedDeviceId: deviceId,
130              pairedDeviceName: name,
131            },
132          }
133        })
134        logger.info(`Paired with "${name}" (${deviceId.slice(0, 8)})`)
135      },
136      getPersistedDeviceId: () => {
137        return getGlobalConfig().chromeExtension?.pairedDeviceId
138      },
139      ...(chromeBridgeUrl && {
140        bridgeConfig: {
141          url: chromeBridgeUrl,
142          getUserId: async () => {
143            return getGlobalConfig().oauthAccount?.accountUuid
144          },
145          getOAuthToken: async () => {
146            return getClaudeAIOAuthTokens()?.accessToken ?? ''
147          },
148          ...(isLocalBridge() && { devUserId: 'dev_user_local' }),
149        },
150      }),
151      ...(initialPermissionMode && { initialPermissionMode }),
152      // Wire inference for the browser_task tool — the chrome-mcp server runs
153      // a lightning-mode agent loop in Node and calls the extension's
154      // lightning_turn tool once per iteration for execution.
155      //
156      // Ant-only: the extension's lightning_turn is build-time-gated via
157      // import.meta.env.ANT_ONLY_BUILD — the whole lightning/ module graph is
158      // tree-shaken from the public extension build (build:prod greps for a
159      // marker to verify). Without this injection, the Node MCP server's
160      // ListTools also filters browser_task + lightning_turn out, so external
161      // users never see the tools advertised. Three independent gates.
162      //
163      // Types inlined: AnthropicMessagesRequest/Response live in
164      // @ant/claude-for-chrome-mcp@0.4.0 which isn't published yet. CI installs
165      // 0.3.0. The callAnthropicMessages field is also 0.4.0-only, but spreading
166      // an extra property into ClaudeForChromeContext is fine against either
167      // version — 0.3.0 sees an unknown field (allowed in spread), 0.4.0 sees a
168      // structurally-matching one. Once 0.4.0 is published, this can switch to
169      // the package's exported types and the dep can be bumped.
170      ...(process.env.USER_TYPE === 'ant' && {
171        callAnthropicMessages: async (req: {
172          model: string
173          max_tokens: number
174          system: string
175          messages: Parameters<typeof sideQuery>[0]['messages']
176          stop_sequences?: string[]
177          signal?: AbortSignal
178        }): Promise<{
179          content: Array<{ type: 'text'; text: string }>
180          stop_reason: string | null
181          usage?: { input_tokens: number; output_tokens: number }
182        }> => {
183          // sideQuery handles OAuth attribution fingerprint, proxy, model betas.
184          // skipSystemPromptPrefix: the lightning prompt is complete on its own;
185          // the CLI prefix would dilute the batching instructions.
186          // tools: [] is load-bearing — without it Sonnet emits
187          // <function_calls> XML before the text commands. Original
188          // lightning-harness.js (apps repo) does the same.
189          const response = await sideQuery({
190            model: req.model,
191            system: req.system,
192            messages: req.messages,
193            max_tokens: req.max_tokens,
194            stop_sequences: req.stop_sequences,
195            signal: req.signal,
196            skipSystemPromptPrefix: true,
197            tools: [],
198            querySource: 'chrome_mcp',
199          })
200          // BetaContentBlock is TextBlock | ThinkingBlock | ToolUseBlock | ...
201          // Only text blocks carry the model's command output.
202          const textBlocks: Array<{ type: 'text'; text: string }> = []
203          for (const b of response.content) {
204            if (b.type === 'text') {
205              textBlocks.push({ type: 'text', text: b.text })
206            }
207          }
208          return {
209            content: textBlocks,
210            stop_reason: response.stop_reason,
211            usage: {
212              input_tokens: response.usage.input_tokens,
213              output_tokens: response.usage.output_tokens,
214            },
215          }
216        },
217      }),
218      trackEvent: (eventName, metadata) => {
219        const safeMetadata: {
220          [key: string]:
221            | boolean
222            | number
223            | AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
224            | undefined
225        } = {}
226        if (metadata) {
227          for (const [key, value] of Object.entries(metadata)) {
228            // Rename 'status' to 'bridge_status' to avoid Datadog's reserved field
229            const safeKey = key === 'status' ? 'bridge_status' : key
230            if (typeof value === 'boolean' || typeof value === 'number') {
231              safeMetadata[safeKey] = value
232            } else if (
233              typeof value === 'string' &&
234              SAFE_BRIDGE_STRING_KEYS.has(safeKey)
235            ) {
236              // Only forward allowlisted string keys — fields like error_message
237              // could contain page content or user data
238              safeMetadata[safeKey] =
239                value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
240            }
241          }
242        }
243        logEvent(eventName, safeMetadata)
244      },
245    }
246  }
247  
248  export async function runClaudeInChromeMcpServer(): Promise<void> {
249    enableConfigs()
250    initializeAnalyticsSink()
251    const context = createChromeContext()
252  
253    const server = createClaudeForChromeMcpServer(context)
254    const transport = new StdioServerTransport()
255  
256    // Exit when parent process dies (stdin pipe closes).
257    // Flush analytics before exiting so final-batch events (e.g. disconnect) aren't lost.
258    let exiting = false
259    const shutdownAndExit = async (): Promise<void> => {
260      if (exiting) {
261        return
262      }
263      exiting = true
264      await shutdown1PEventLogging()
265      await shutdownDatadog()
266      // eslint-disable-next-line custom-rules/no-process-exit
267      process.exit(0)
268    }
269    process.stdin.on('end', () => void shutdownAndExit())
270    process.stdin.on('error', () => void shutdownAndExit())
271  
272    logForDebugging('[Claude in Chrome] Starting MCP server')
273    await server.connect(transport)
274    logForDebugging('[Claude in Chrome] MCP server started')
275  }
276  
277  class DebugLogger implements Logger {
278    silly(message: string, ...args: unknown[]): void {
279      logForDebugging(format(message, ...args), { level: 'debug' })
280    }
281    debug(message: string, ...args: unknown[]): void {
282      logForDebugging(format(message, ...args), { level: 'debug' })
283    }
284    info(message: string, ...args: unknown[]): void {
285      logForDebugging(format(message, ...args), { level: 'info' })
286    }
287    warn(message: string, ...args: unknown[]): void {
288      logForDebugging(format(message, ...args), { level: 'warn' })
289    }
290    error(message: string, ...args: unknown[]): void {
291      logForDebugging(format(message, ...args), { level: 'error' })
292    }
293  }