Files
tolaria/src/components/McpSetupDialog.tsx
Konstantin Zadorozhny ab123a1428 feat: support Bun as MCP server runtime
Tolaria's MCP server is pure ESM with only standard `node:fs|path|http|child_process`
imports and pure-JS dependencies, so Bun can execute it identically to Node.
Until now `find_node()` was the single entry point for spawning the WebSocket
bridge and writing external AI tool config — users with Bun but no Node would
hit "node not found in PATH or common install locations" and lose access to MCP
tools entirely.

Introduce `find_mcp_runtime()` which returns the first verifying runtime,
preferring Node 18+ when present and falling back to Bun 1+. The generic
PATH and login-shell lookup helpers (`find_on_path`, `find_in_user_shell`,
`lookup_command`, `lookup_paths`) are now parameterised by command name so
both runtimes share the same machinery. Bun candidates cover `~/.bun/bin/bun`,
mise/asdf/proto shims, Homebrew, and `%USERPROFILE%\.bun\bin\bun.exe` on
Windows. The Codex CLI and Windows .cmd-shim resolution paths keep using the
strict `find_node()` since they specifically need Node.

`spawn_ws_bridge_with_paths`, `mcp_config_snippet`, and `register_mcp` now
resolve through `find_mcp_runtime` so the runtime that gets registered into
`~/.claude.json`, `~/.gemini/settings.json`, `~/.cursor/mcp.json`, etc.
matches the one actually present on the machine.

Locale copy updated from "nodeRequirement" to "runtimeRequirement" across
all 15 catalogs to reflect "Node.js 18+ or Bun 1+".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:42:31 -07:00

241 lines
6.7 KiB
TypeScript

import { Copy, ShieldCheck } from '@phosphor-icons/react'
import { useEffect } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { McpStatus } from '../hooks/useMcpStatus'
import { createTranslator, type AppLocale } from '../lib/i18n'
interface McpSetupDialogProps {
open: boolean
status: McpStatus
busyAction: 'connect' | 'disconnect' | null
manualConfigError?: string | null
manualConfigLoading?: boolean
manualConfigSnippet?: string | null
locale?: AppLocale
onClose: () => void
onConnect: () => void
onCopyManualConfig?: () => void
onDisconnect: () => void
onLoadManualConfig?: () => void
}
interface ManualMcpConfigSectionProps {
error?: string | null
loading: boolean
locale?: AppLocale
onCopy?: () => void
snippet?: string | null
}
interface McpSetupActionsProps {
buttonsDisabled: boolean
connectBusy: boolean
disconnectBusy: boolean
locale?: AppLocale
primaryLabel: string
secondaryLabel: string | null
onClose: () => void
onConnect: () => void
onDisconnect: () => void
}
function isConnected(status: McpStatus): boolean {
return status === 'installed'
}
function actionCopy(status: McpStatus, t: ReturnType<typeof createTranslator>) {
if (isConnected(status)) {
return {
description: t('mcp.setup.connectedDescription'),
primaryLabel: t('mcp.setup.reconnect'),
secondaryLabel: t('mcp.setup.disconnect'),
title: t('mcp.setup.manageTitle'),
}
}
return {
description: t('mcp.setup.setupDescription'),
primaryLabel: t('mcp.setup.connect'),
secondaryLabel: null,
title: t('mcp.setup.setupTitle'),
}
}
function manualConfigText(
{ error, loading, snippet }: ManualMcpConfigSectionProps,
t: ReturnType<typeof createTranslator>,
): string {
if (loading) return t('mcp.setup.manual.loading')
return error ?? snippet ?? t('mcp.setup.manual.unavailable')
}
function ManualMcpConfigSection(props: ManualMcpConfigSectionProps) {
const t = createTranslator(props.locale)
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="m-0 text-sm font-medium text-foreground">{t('mcp.setup.manual.title')}</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={props.onCopy}
disabled={!props.onCopy || props.loading}
data-testid="mcp-copy-config"
>
<Copy size={14} />
{t('mcp.setup.manual.copy')}
</Button>
</div>
<pre
tabIndex={0}
className="max-h-48 overflow-auto rounded-md border border-border bg-background px-3 py-3 font-mono text-xs leading-5 text-foreground"
data-testid="mcp-config-snippet"
>
{manualConfigText(props, t)}
</pre>
</div>
)
}
function McpSetupActions({
buttonsDisabled,
connectBusy,
disconnectBusy,
locale = 'en',
primaryLabel,
secondaryLabel,
onClose,
onConnect,
onDisconnect,
}: McpSetupActionsProps) {
const t = createTranslator(locale)
return (
<DialogFooter
className="shrink-0 flex-row flex-wrap items-center justify-end gap-2 sm:justify-end"
data-testid="mcp-setup-actions"
>
<Button type="button" variant="outline" onClick={onClose} disabled={buttonsDisabled}>
{t('common.cancel')}
</Button>
{secondaryLabel ? (
<Button
type="button"
variant="destructive"
onClick={onDisconnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-disconnect"
>
{disconnectBusy ? t('mcp.setup.disconnecting') : secondaryLabel}
</Button>
) : null}
<Button
type="button"
autoFocus
onClick={onConnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-connect"
>
{connectBusy ? t('mcp.setup.connecting') : primaryLabel}
</Button>
</DialogFooter>
)
}
export function McpSetupDialog({
open,
status,
busyAction,
manualConfigError,
manualConfigLoading = false,
manualConfigSnippet,
locale = 'en',
onClose,
onConnect,
onCopyManualConfig,
onDisconnect,
onLoadManualConfig,
}: McpSetupDialogProps) {
const t = createTranslator(locale)
const copy = actionCopy(status, t)
const connectBusy = busyAction === 'connect'
const disconnectBusy = busyAction === 'disconnect'
const buttonsDisabled = busyAction !== null || status === 'checking'
useEffect(() => {
if (open) onLoadManualConfig?.()
}, [open, onLoadManualConfig])
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
<DialogContent
showCloseButton={false}
className="flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden sm:max-w-[520px]"
data-testid="mcp-setup-dialog"
>
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<ShieldCheck size={18} />
{copy.title}
</DialogTitle>
<DialogDescription>{copy.description}</DialogDescription>
</DialogHeader>
<div
className="min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain pr-1 text-sm leading-6 text-muted-foreground"
data-testid="mcp-setup-scroll-body"
>
<p>
{t('mcp.setup.runtimeRequirement')}
</p>
<p>
{t('mcp.setup.writeEntryDescription', { entry: 'tolaria' })}
</p>
<div className="rounded-md border border-border bg-muted/30 px-3 py-3 font-mono text-xs text-foreground">
<div>~/.claude.json</div>
<div>~/.claude/mcp.json</div>
<div>~/.gemini/settings.json</div>
<div>~/.cursor/mcp.json</div>
<div>~/.config/mcp/mcp.json</div>
</div>
<ManualMcpConfigSection
error={manualConfigError}
loading={manualConfigLoading}
locale={locale}
onCopy={onCopyManualConfig}
snippet={manualConfigSnippet}
/>
<p>
{t('mcp.setup.clientPathsDescription')}
</p>
<p>
{t('mcp.setup.geminiGuidanceDescription')}
</p>
</div>
<McpSetupActions
buttonsDisabled={buttonsDisabled}
connectBusy={connectBusy}
disconnectBusy={disconnectBusy}
locale={locale}
primaryLabel={copy.primaryLabel}
secondaryLabel={copy.secondaryLabel}
onClose={onClose}
onConnect={onConnect}
onDisconnect={onDisconnect}
/>
</DialogContent>
</Dialog>
)
}