diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 2591e59..028c90b 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -7,6 +7,8 @@ We’ve restored full compatibility with the Official MCP Registry and just adde What’s new +- Unified install controls let you choose between VS Code and Claude Code before running a single install button. +- Claude CLI installs now run through a background VS Code task with inline prompts, success toasts, and guidance when the CLI isn’t installed (including a one-click copy of the generated command). - Remote endpoints now install cleanly with automatic `${input:...}` prompts for tokens and headers. - Registry searches are working again, so results appear immediately with updated metadata. - Remote MCP server results will show an "Install Remote" button that will set up VSCode with the server. diff --git a/src/panels/ExtensionPanel.ts b/src/panels/ExtensionPanel.ts index 8110408..41f9a11 100644 --- a/src/panels/ExtensionPanel.ts +++ b/src/panels/ExtensionPanel.ts @@ -19,17 +19,25 @@ import { import { TelemetryEvents } from "../telemetry/types"; import { Messenger } from "vscode-messenger"; import { - aiAssistedSetupType, - deleteServerType, - getMcpConfigType, - searchServersType, - sendFeedbackType, - updateMcpConfigType, - updateServerEnvVarType, - cloudMCPInterestType, - previewReadmeType, - installFromConfigType, - registrySearchType, + aiAssistedSetupType, + deleteServerType, + getMcpConfigType, + searchServersType, + sendFeedbackType, + updateMcpConfigType, + updateServerEnvVarType, + cloudMCPInterestType, + previewReadmeType, + installFromConfigType, + installClaudeFromConfigType, + registrySearchType, +} from "../shared/types/rpcTypes"; +import type { + InstallCommandPayload, + InstallInput, + InstallTransport, + ClaudeInstallRequest, + ClaudeInstallResponse, } from "../shared/types/rpcTypes"; import axios from "axios"; @@ -51,7 +59,7 @@ async function getServersFromMcpJsonFile( // Consolidates servers from global settings, workspace settings, and .vscode/mcp.json files async function getAllServers(): Promise> { - const config = vscode.workspace.getConfiguration("mcp"); + const config = vscode.workspace.getConfiguration("mcp"); // 1. Get servers from global settings const globalServers = config.inspect>("servers")?.globalValue || {}; @@ -74,7 +82,222 @@ async function getAllServers(): Promise> { mergedServers = { ...mergedServers, ...workspaceSettingsServers }; mergedServers = { ...mergedServers, ...mcpJsonFileServers }; - return mergedServers; + return mergedServers; +} + +const INPUT_PLACEHOLDER_REGEX = /\\?\${input:([^}]+)}/g; + +function replaceInputPlaceholders(value: string, replacements: Map): string { + return value.replace(INPUT_PLACEHOLDER_REGEX, (_, rawId) => { + const key = String(rawId ?? "").trim(); + if (!key) { + return ""; + } + return replacements.get(key) ?? ""; + }); +} + +function applyInputsToPayload( + payload: InstallCommandPayload, + replacements: Map +): InstallCommandPayload { + const args = payload.args?.map((arg) => replaceInputPlaceholders(arg, replacements)); + const envEntries = payload.env ? Object.entries(payload.env) : []; + const env = envEntries.length + ? envEntries.reduce>((acc, [key, value]) => { + acc[key] = replaceInputPlaceholders(value, replacements); + return acc; + }, {}) + : undefined; + const headers = payload.headers?.map((header) => ({ + name: header.name, + value: header.value !== undefined ? replaceInputPlaceholders(header.value, replacements) : header.value, + })); + + return { + ...payload, + args, + env, + headers, + inputs: undefined, + }; +} + +async function collectInstallInputs(inputs?: InstallInput[]): Promise<{ + values: Map; + canceled: boolean; +}> { + const values = new Map(); + if (!inputs || inputs.length === 0) { + return { values, canceled: false }; + } + + for (const input of inputs) { + if (values.has(input.id)) { + continue; + } + const response = await vscode.window.showInputBox({ + prompt: input.description, + password: input.password ?? false, + ignoreFocusOut: true, + }); + if (response === undefined) { + return { values, canceled: true }; + } + values.set(input.id, response); + } + + return { values, canceled: false }; +} + +function headersArrayToRecord(headers?: Array<{ name: string; value: string }>) { + if (!headers) { + return undefined; + } + const record: Record = {}; + for (const header of headers) { + if (!header?.name) { + continue; + } + record[header.name] = header.value ?? ""; + } + return Object.keys(record).length > 0 ? record : undefined; +} + +function buildClaudeConfigObject(payload: InstallCommandPayload, transport: InstallTransport) { + const config: Record = { type: transport }; + if (transport === "stdio") { + if (payload.command) { + config.command = payload.command; + } + if (payload.args && payload.args.length > 0) { + config.args = payload.args; + } + if (payload.env && Object.keys(payload.env).length > 0) { + config.env = payload.env; + } + } else { + if (payload.url) { + config.url = payload.url; + } + const headerRecord = headersArrayToRecord(payload.headers); + if (headerRecord) { + config.headers = headerRecord; + } + } + return config; +} + +async function performVscodeInstall(payload: InstallCommandPayload) { + const response = await openMcpInstallUri(payload); + return !!(response && (response as any).success); +} + +async function runClaudeCliTask( + name: string, + transport: InstallTransport, + config: Record, +): Promise { + const claudeBinary = process.platform === "win32" ? "claude.exe" : "claude"; + const configJson = JSON.stringify(config); + const shellExecution = new vscode.ShellExecution(claudeBinary, [ + "mcp", + "add-json", + name, + configJson, + ]); + + const definition: vscode.TaskDefinition = { type: "claude-cli", command: "add-json" }; + const scope = vscode.workspace.workspaceFolders?.[0] ?? vscode.TaskScope.Workspace; + const task = new vscode.Task(definition, scope, `Claude MCP Install (${name})`, "Claude CLI", shellExecution); + task.presentationOptions = { + reveal: vscode.TaskRevealKind.Never, + focus: false, + showReuseMessage: false, + clear: true, + }; + + return await new Promise((resolve) => { + let resolved = false; + const disposables: vscode.Disposable[] = []; + let execution: vscode.TaskExecution | undefined; + + const cleanup = () => { + for (const disposable of disposables) { + disposable.dispose(); + } + }; + + disposables.push( + vscode.tasks.onDidEndTaskProcess((event) => { + if (!execution || event.execution !== execution || resolved) { + return; + } + resolved = true; + cleanup(); + + const exitCode = event.exitCode; + if (exitCode === undefined || exitCode === null) { + resolve({ + success: false, + cliAvailable: false, + errorMessage: "Claude CLI command could not be started.", + }); + return; + } + if (exitCode === 127) { + resolve({ + success: false, + cliAvailable: false, + errorMessage: "Claude CLI was not found (exit code 127).", + }); + return; + } + if (exitCode === 0) { + resolve({ success: true, cliAvailable: true }); + } else { + resolve({ + success: false, + cliAvailable: true, + errorMessage: `Claude CLI exited with code ${exitCode}.`, + }); + } + }), + ); + + disposables.push( + vscode.tasks.onDidEndTask((event) => { + if (!execution || event.execution !== execution || resolved) { + return; + } + resolved = true; + cleanup(); + resolve({ + success: false, + cliAvailable: false, + errorMessage: "Claude CLI task ended unexpectedly.", + }); + }), + ); + + vscode.tasks.executeTask(task).then( + (taskExecution) => { + execution = taskExecution; + }, + (error) => { + if (resolved) { + return; + } + resolved = true; + cleanup(); + const message = + error instanceof Error + ? error.message + : "Failed to launch Claude CLI."; + resolve({ success: false, cliAvailable: false, errorMessage: message }); + }, + ); + }); } @@ -184,15 +407,52 @@ export class CopilotMcpViewProvider implements vscode.WebviewViewProvider { }); // Direct install path from structured config (Official Registry results) - messenger.onRequest(installFromConfigType, async (payload) => { - try { - const cmdResponse = await openMcpInstallUri(payload as any); - return !!(cmdResponse && (cmdResponse as any).success); - } catch (error) { - console.error("Error during direct install: ", error); - return false; - } - }); + messenger.onRequest(installFromConfigType, async (payload) => { + try { + logWebviewInstallAttempt(payload.name); + return await performVscodeInstall(payload); + } catch (error) { + console.error("Error during direct install: ", error); + logError(error as Error, "registry-install", { target: "vscode" }); + return false; + } + }); + + messenger.onRequest(installClaudeFromConfigType, async (payload: ClaudeInstallRequest) => { + logWebviewInstallAttempt(payload.name); + const { values, canceled } = await collectInstallInputs(payload.inputs); + if (canceled) { + return { success: false, cliAvailable: true, canceled: true } satisfies ClaudeInstallResponse; + } + + const substitutedPayload = applyInputsToPayload(payload, values); + + try { + const config = buildClaudeConfigObject(substitutedPayload, payload.transport); + const result = await runClaudeCliTask(payload.name, payload.transport, config); + if (result.success) { + void vscode.window.showInformationMessage( + `Claude CLI added ${payload.name}.`, + ); + } else if (result.cliAvailable && result.errorMessage) { + logError(new Error(result.errorMessage), "claude-cli-install", { + transport: payload.transport, + mode: payload.mode, + }); + } + return result; + } catch (error) { + logError(error as Error, "claude-cli-install", { + transport: payload.transport, + mode: payload.mode, + }); + return { + success: false, + cliAvailable: true, + errorMessage: error instanceof Error ? error.message : String(error), + } satisfies ClaudeInstallResponse; + } + }); // Official Registry search proxied via extension (avoids webview CORS) messenger.onRequest(registrySearchType, async (payload) => { @@ -202,7 +462,9 @@ export class CopilotMcpViewProvider implements vscode.WebviewViewProvider { limit: payload.limit ?? 10, search: payload.search, }; - if (payload.cursor) params.cursor = payload.cursor; + if (payload.cursor) { + params.cursor = payload.cursor; + } const res = await axios.get('https://registry.modelcontextprotocol.io/v0/servers', { params }); const data = res?.data ?? {}; return { diff --git a/src/shared/types/rpcTypes.ts b/src/shared/types/rpcTypes.ts index ced993c..c8e828b 100644 --- a/src/shared/types/rpcTypes.ts +++ b/src/shared/types/rpcTypes.ts @@ -1,8 +1,41 @@ import { - type RequestType, - type NotificationType, + type RequestType, + type NotificationType, } from "vscode-messenger-common"; +export type InstallInput = { + type: "promptString"; + id: string; + description?: string; + password?: boolean; +}; + +export interface InstallCommandPayload { + name: string; + command?: string; + args?: string[]; + env?: Record; + url?: string; + headers?: Array<{ name: string; value: string }>; + inputs?: InstallInput[]; +} + +export type InstallTransport = "stdio" | "http" | "sse"; + +export type InstallMode = "package" | "remote"; + +export interface ClaudeInstallRequest extends InstallCommandPayload { + transport: InstallTransport; + mode: InstallMode; +} + +export interface ClaudeInstallResponse { + success: boolean; + cliAvailable: boolean; + errorMessage?: string; + canceled?: boolean; +} + export const searchServersType: RequestType< { query: string; page?: number; @@ -56,15 +89,13 @@ export const previewReadmeType: NotificationType<{ }> = { method: "previewReadme" }; // Direct installation from a structured config (used by Official Registry results) -export const installFromConfigType: RequestType<{ - name: string; - command?: string; - args?: string[]; - env?: Record; - inputs?: Array<{ type: 'promptString'; id: string; description?: string; password?: boolean }>; - url?: string; // for remote installs - headers?: Array<{ name: string; value: string }>; // for remote installs with headers -}, boolean> = { method: "installFromConfig" }; +export const installFromConfigType: RequestType = { + method: "installFromConfig", +}; + +export const installClaudeFromConfigType: RequestType = { + method: "installClaudeFromConfig", +}; // Official Registry search (proxied via extension to avoid CORS) export const registrySearchType: RequestType< diff --git a/web/src/components/RegistryServerCard.tsx b/web/src/components/RegistryServerCard.tsx index 3cc5db9..830cc27 100644 --- a/web/src/components/RegistryServerCard.tsx +++ b/web/src/components/RegistryServerCard.tsx @@ -9,16 +9,36 @@ import { } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { useVscodeApi } from "@/contexts/VscodeApiContext"; import { Messenger } from "vscode-messenger-webview"; -import { installFromConfigType } from "../../../src/shared/types/rpcTypes"; +import { + installClaudeFromConfigType, + installFromConfigType, + type InstallMode, +} from "../../../src/shared/types/rpcTypes"; import type { - RegistryKeyValueInput, RegistryPackage, RegistryServer, RegistryServerResponse, RegistryTransport, } from "@/types/registry"; +import { + ProgramTarget, + buildPackageInstall, + buildRemoteInstall, + copyClaudeCommand, + createClaudeAddJsonCommand, +} from "@/utils/registryInstall"; + +const CLAUDE_DOCS_URL = + "https://github.com/vikashloomba/copilot-mcp/blob/main/mcp.md#add-mcp-servers-from-json-configuration"; + +type InstallErrorState = { + message: string; + missingCli?: boolean; + cliCommand?: string; +}; interface RegistryServerCardProps { serverResponse: RegistryServerResponse; @@ -27,27 +47,31 @@ interface RegistryServerCardProps { const RegistryServerCard: React.FC = ({ serverResponse }) => { const vscodeApi = useVscodeApi(); const messenger = useMemo(() => new Messenger(vscodeApi), [vscodeApi]); - const [isInstallingLocal, setIsInstallingLocal] = useState(false); - const [isInstallingRemote, setIsInstallingRemote] = useState(false); const server = (serverResponse?.server ?? serverResponse) as RegistryServer | undefined; - const packages: RegistryPackage[] = Array.isArray(server?.packages) ? server?.packages ?? [] : []; + const packages: RegistryPackage[] = Array.isArray(server?.packages) ? server.packages ?? [] : []; const remotes: RegistryTransport[] = Array.isArray(server?.remotes) - ? (server?.remotes ?? []).filter((r): r is RegistryTransport => typeof r?.url === 'string' && r.url.length > 0) + ? (server.remotes ?? []).filter((remote): remote is RegistryTransport => Boolean(remote?.url)) : []; const hasLocal = packages.length > 0; const hasRemote = remotes.length > 0; - // Prefer stdio package for default selection - const defaultPackage = packages.find((p) => p?.transport?.type === 'stdio') || packages[0]; + const defaultPackage = packages.find((pkg) => pkg?.transport?.type === 'stdio') || packages[0]; const [selectedPackageId, setSelectedPackageId] = useState(defaultPackage?.identifier); const [selectedRemoteIdx, setSelectedRemoteIdx] = useState(hasRemote ? '0' : undefined); + const [programTarget, setProgramTarget] = useState('vscode'); + const [installMode, setInstallMode] = useState(hasLocal ? 'package' : 'remote'); + const [isInstalling, setIsInstalling] = useState(false); + const [installError, setInstallError] = useState(null); + const [installStatus, setInstallStatus] = useState(null); + const [lastClaudeCommand, setLastClaudeCommand] = useState(null); + const [copyFeedback, setCopyFeedback] = useState<'idle' | 'copied' | 'error'>('idle'); useEffect(() => { messenger.start(); }, [messenger]); useEffect(() => { - if (!packages.find((p) => p.identifier === selectedPackageId)) { + if (!packages.find((pkg) => pkg.identifier === selectedPackageId)) { setSelectedPackageId(defaultPackage?.identifier); } }, [packages, defaultPackage?.identifier, selectedPackageId]); @@ -58,237 +82,148 @@ const RegistryServerCard: React.FC = ({ serverResponse return; } const currentIndex = Number(selectedRemoteIdx ?? '0'); - if ( - Number.isNaN(currentIndex) || - currentIndex < 0 || - currentIndex >= remotes.length - ) { + if (Number.isNaN(currentIndex) || currentIndex < 0 || currentIndex >= remotes.length) { setSelectedRemoteIdx('0'); } }, [hasRemote, remotes, selectedRemoteIdx]); - const findSelectedPackage = (): RegistryPackage | undefined => - packages.find((p) => p.identifier === selectedPackageId); - - const buildArgsAndInputs = (pkg?: RegistryPackage) => { - const args: string[] = []; - const argInputs: Array<{ type: 'promptString'; id: string; description?: string; password?: boolean }> = []; - let positionalIndex = 0; - const sanitizeId = (s: string) => s.replace(/^--?/, '').replace(/[^a-zA-Z0-9_]+/g, '_'); - - const pushArgList = (list?: Array | null) => { - if (!Array.isArray(list)) return; - for (const a of list) { - const t = a?.type as string | undefined; - const name = a?.name as string | undefined; - const value = a?.value as string | undefined; - const valueHint = (a?.valueHint ?? a?.default) as string | undefined; - const isSecret = !!a?.isSecret; - const description = a?.description as string | undefined; + useEffect(() => { + if (installMode === 'package' && !hasLocal && hasRemote) { + setInstallMode('remote'); + } else if (installMode === 'remote' && !hasRemote && hasLocal) { + setInstallMode('package'); + } + }, [installMode, hasLocal, hasRemote]); - if (t === 'positional') { - if (value) { - args.push(value); - } else if (valueHint) { - args.push(valueHint); - } else { - const id = `arg_${positionalIndex++}`; - argInputs.push({ type: 'promptString', id, description: description || 'Provide value', password: !!isSecret }); - args.push(`\${input:${id}}`); - } - } else if (t === 'named') { - if (name) args.push(name); - if (value) { - args.push(value); - } else if (valueHint) { - args.push(valueHint); - } else { - const base = name ? sanitizeId(name) : `arg_${positionalIndex++}`; - const id = `arg_${base}`; - argInputs.push({ type: 'promptString', id, description: description || `Value for ${name || 'argument'}`, password: !!isSecret }); - args.push(`\${input:${id}}`); - } - } - } - }; - pushArgList(pkg?.runtimeArguments ?? undefined); - pushArgList(pkg?.packageArguments ?? undefined); - return { args, inputs: argInputs }; - }; + useEffect(() => { + setInstallError(null); + setInstallStatus(null); + setCopyFeedback('idle'); + }, [installMode, programTarget, selectedPackageId, selectedRemoteIdx]); - const buildEnvAndInputs = (pkg?: RegistryPackage) => { - const env: Record = {}; - const inputs: Array<{ type: 'promptString'; id: string; description?: string; password?: boolean }>= []; - if (Array.isArray(pkg?.environmentVariables)) { - for (const v of pkg!.environmentVariables!) { - const key = v?.name as string | undefined; - const isSecret = !!v?.isSecret; - const def = (v?.value ?? v?.default) as string | undefined; - const description = v?.description as string | undefined; - if (!key) continue; - // If no default, always prompt. Use password for secrets - if (typeof def === 'string' && def.length > 0) { - env[key] = def; - } else { - inputs.push({ type: 'promptString', id: key, description, password: !!isSecret }); - env[key] = `\${input:${key}}`; - } - } - } - return { env, inputs }; - }; + const selectedPackage = useMemo( + () => packages.find((pkg) => pkg.identifier === selectedPackageId), + [packages, selectedPackageId], + ); - const resolveCommand = (pkg?: RegistryPackage): { command?: string; unsupported?: boolean } => { - if (!pkg) return { command: undefined }; - if (pkg.runtimeHint) return { command: pkg.runtimeHint }; - const type = (pkg.registryType || '').toLowerCase(); - if (type === 'npm') return { command: 'npx' }; - if (type === 'pypi') return { command: 'uvx' }; - if (type === 'oci') return { command: 'docker' }; - // Per requirements, do not auto-map nuget or mcpb without hint - return { command: undefined, unsupported: true }; - }; + const selectedRemote = useMemo(() => { + if (!hasRemote || selectedRemoteIdx === undefined) return undefined; + const index = Number(selectedRemoteIdx); + if (Number.isNaN(index) || index < 0 || index >= remotes.length) return undefined; + return remotes[index]; + }, [hasRemote, remotes, selectedRemoteIdx]); - const onInstallLocal = async () => { - const pkg = findSelectedPackage(); - if (!pkg) return; - const { command, unsupported } = resolveCommand(pkg); - if (unsupported || !command) { - // Basic guard; disable button in UI too - return; - } - setIsInstallingLocal(true); - try { - const { args, inputs: argInputs } = buildArgsAndInputs(pkg); - const { env, inputs: envInputs } = buildEnvAndInputs(pkg); - // Ensure base package spec is included for commands like npx/uvx - const baseArgs: string[] = []; - if (command === 'npx' && pkg.identifier) { - const spec = pkg.version ? `${pkg.identifier}@${pkg.version}` : pkg.identifier; - // Avoid duplicating if already present in args - if (!args.some((a) => typeof a === 'string' && a.includes(pkg.identifier!))) { - baseArgs.push(spec); - } - } else if (command === 'uvx' && pkg.identifier) { - const spec = pkg.version && pkg.version !== 'latest' ? `${pkg.identifier}==${pkg.version}` : pkg.identifier; - if (!args.some((a) => typeof a === 'string' && a.includes(pkg.identifier!))) { - baseArgs.push(spec); - } - } - const payload = { - name: pkg.identifier || server?.name || 'server', - command, - args: [...baseArgs, ...args], - env, - inputs: [...argInputs, ...envInputs], - }; - await messenger.sendRequest(installFromConfigType, { type: 'extension' }, payload); - } finally { - setIsInstallingLocal(false); - } - }; + const packageBuild = useMemo(() => buildPackageInstall(server, selectedPackage), [server, selectedPackage]); + const remoteBuild = useMemo(() => buildRemoteInstall(server, selectedRemote), [server, selectedRemote]); + const activeBuild = installMode === 'package' ? packageBuild : remoteBuild; - const onInstallRemote = async () => { - if (!hasRemote) return; - const idx = Number(selectedRemoteIdx || '0'); - const remote = remotes[idx]; - const pkg = findSelectedPackage(); - if (!remote) return; - setIsInstallingRemote(true); - try { - const headerInputs: Array<{ type: 'promptString'; id: string; description?: string; password?: boolean }> = []; - const headers: Array<{ name: string; value: string }> = []; - const sanitizeId = (s: string) => s.replace(/[^a-zA-Z0-9_]+/g, '_'); + const title = selectedPackage?.identifier || server?.name || 'MCP Server'; + const description = server?.description || ''; + const repoUrl = server?.repository?.url; + const websiteUrl = server?.websiteUrl; - const escapeRegExp = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const programLabel = programTarget === 'vscode' ? 'VS Code' : 'Claude Code'; + const buttonLabel = isInstalling + ? `Installing in ${programLabel}…` + : installMode === 'package' + ? `Install Package in ${programLabel}` + : programTarget === 'claude' + ? `Add Remote to ${programLabel}` + : `Install Remote in ${programLabel}`; - if (Array.isArray(remote.headers)) { - for (const rawHeader of remote.headers as RegistryKeyValueInput[]) { - const name = rawHeader.name?.trim(); - if (!name) continue; - const desc = rawHeader.description || undefined; - const isSecret = !!rawHeader.isSecret; - const isRequired = !!rawHeader.isRequired; - const variableRecord = rawHeader.variables && typeof rawHeader.variables === 'object' - ? rawHeader.variables - : {}; - const template = (rawHeader.value ?? rawHeader.default ?? '') as string; - const placeholders = new Set(); + const isInstallDisabled = + isInstalling || + !activeBuild.payload || + Boolean(activeBuild.unavailableReason) || + (installMode === 'package' && !selectedPackage) || + (installMode === 'remote' && (!hasRemote || !selectedRemote)) || + (programTarget === 'claude' && !activeBuild.transport); - for (const key of Object.keys(variableRecord)) { - if (key) placeholders.add(key); - } + const onInstall = async () => { + if (!activeBuild.payload) return; - const placeholderPattern = /\{([^{}]+)\}|\$\{([^{}]+)\}/g; - let match: RegExpExecArray | null; - while ((match = placeholderPattern.exec(template)) !== null) { - const key = (match[1] ?? match[2])?.trim(); - if (key) placeholders.add(key); - } + setIsInstalling(true); + setInstallError(null); + setInstallStatus(null); + setCopyFeedback('idle'); + setLastClaudeCommand(null); - let value = template; - const ensureInput = (id: string, descriptionText?: string, password?: boolean) => { - if (!id) return; - if (!headerInputs.some((i) => i.id === id)) { - headerInputs.push({ type: 'promptString', id, description: descriptionText, password }); - } - }; + try { + if (programTarget === 'vscode') { + const success = await messenger.sendRequest(installFromConfigType, { type: 'extension' }, activeBuild.payload); + if (success) { + setInstallStatus('VS Code is opening the MCP install prompt.'); + } else { + setInstallError({ message: 'Unable to start the VS Code install flow. Please try again.' }); + } + return; + } - for (const id of placeholders) { - const variable = variableRecord[id]; - const descriptionText = variable?.description || desc; - const password = variable?.isSecret ?? isSecret; - ensureInput(id, descriptionText, password); - const escapedId = escapeRegExp(id); - value = value.replace(new RegExp(`\\{${escapedId}\\}`, 'g'), `\${input:${id}}`); - value = value.replace(new RegExp(`\\$\\{${escapedId}\\}`, 'g'), `\${input:${id}}`); - } + if (!activeBuild.transport) { + setInstallError({ message: 'This install mode is not supported for Claude Code yet.' }); + return; + } - if (!value && placeholders.size > 0) { - const [first] = Array.from(placeholders); - if (first) { - const variable = variableRecord[first]; - ensureInput(first, variable?.description || desc, variable?.isSecret ?? isSecret); - value = `\${input:${first}}`; - } - } + const response = await messenger.sendRequest(installClaudeFromConfigType, { type: 'extension' }, { + ...activeBuild.payload, + transport: activeBuild.transport, + mode: installMode, + }); - const needsPrompt = (isSecret && !value?.includes('${input:')) || (!value && (isSecret || isRequired)); + if (response.canceled) { + setInstallStatus('Claude installation canceled.'); + return; + } - if (needsPrompt) { - const baseId = sanitizeId(name) || 'value'; - const fallbackId = `header_${baseId}`; - ensureInput(fallbackId, desc, isSecret); - value = `\${input:${fallbackId}}`; - } + if (!response.cliAvailable) { + const command = createClaudeAddJsonCommand( + activeBuild.payload.name, + activeBuild.transport, + activeBuild.payload, + ); + setLastClaudeCommand(command); + setInstallError({ + message: response.errorMessage || 'Claude CLI was not found on your PATH.', + missingCli: true, + cliCommand: command, + }); + return; + } - headers.push({ name, value: value ?? '' }); - } + if (!response.success) { + setInstallError({ + message: response.errorMessage || 'Claude CLI failed to add this MCP server.', + }); + return; } - const payload = { - name: pkg?.identifier || server?.name || 'server', - url: remote.url, - headers: headers.length > 0 ? headers : undefined, - inputs: headerInputs.length > 0 ? headerInputs : undefined, - }; - await messenger.sendRequest(installFromConfigType, { type: 'extension' }, payload); + setInstallStatus('Claude CLI added this MCP server successfully.'); + } catch (error) { + console.error('Error during install', error); + setInstallError({ + message: error instanceof Error ? error.message : 'Unexpected error during install.', + }); } finally { - setIsInstallingRemote(false); + setIsInstalling(false); } }; - const title = (findSelectedPackage()?.identifier) || server?.name || 'MCP Server'; - const description = server?.description || ''; - const repoUrl = server?.repository?.url; - const websiteUrl = server?.websiteUrl; + const onCopyCommand = async () => { + const commandToCopy = lastClaudeCommand + ?? (activeBuild.payload && activeBuild.transport + ? createClaudeAddJsonCommand(activeBuild.payload.name, activeBuild.transport, activeBuild.payload) + : null); - const selectedPkg = findSelectedPackage(); - const remoteIndex = Number(selectedRemoteIdx ?? '0'); - const selectedRemote = hasRemote && Number.isInteger(remoteIndex) ? remotes[remoteIndex] : undefined; - const { unsupported } = resolveCommand(selectedPkg); - const localDisabled = !hasLocal || !selectedPkg || unsupported; - const remoteDisabled = !hasRemote || !server?.name || !selectedRemote?.url; + if (!commandToCopy) { + setCopyFeedback('error'); + return; + } + + const copied = await copyClaudeCommand(commandToCopy); + setCopyFeedback(copied ? 'copied' : 'error'); + }; + + const modeSelectorVisible = hasLocal && hasRemote; return ( @@ -305,7 +240,65 @@ const RegistryServerCard: React.FC = ({ serverResponse Website )} - {hasLocal && ( +
+ Install to: + { + if (value === 'vscode' || value === 'claude') { + setProgramTarget(value); + } + }} + className="gap-2" + > + + VS Code + + + Claude Code + + +
+ {modeSelectorVisible && ( +
+ Mode: + { + if (value === 'package' || value === 'remote') { + setInstallMode(value); + } + }} + className="gap-2" + > + + Package + + + Remote + + +
+ )} + {installMode === 'package' && hasLocal && (
Package:
@@ -314,9 +307,9 @@ const RegistryServerCard: React.FC = ({ serverResponse - {packages.map((p) => ( - - {p.identifier} {p.version ? `@${p.version}` : ''} {p.transport?.type ? `• ${p.transport?.type}` : ''} + {packages.map((pkg) => ( + + {pkg.identifier} {pkg.version ? `@${pkg.version}` : ''} {pkg.transport?.type ? `• ${pkg.transport?.type}` : ''} ))} @@ -324,7 +317,7 @@ const RegistryServerCard: React.FC = ({ serverResponse
)} - {hasRemote && ( + {installMode === 'remote' && hasRemote && (
Remote:
@@ -333,9 +326,9 @@ const RegistryServerCard: React.FC = ({ serverResponse - {remotes.map((r, i) => ( - - {r.type || 'remote'}{r.url ? ` • ${r.url}` : ''} + {remotes.map((remote, index) => ( + + {remote.type || 'remote'}{remote.url ? ` • ${remote.url}` : ''} ))} @@ -343,23 +336,56 @@ const RegistryServerCard: React.FC = ({ serverResponse
)} + {activeBuild.unavailableReason && ( +
{activeBuild.unavailableReason}
+ )} + {installStatus && !installError && ( +
{installStatus}
+ )} + {installError && ( +
+
{installError.message}
+ {installError.missingCli && ( +
+
+ Install the Claude CLI and try again. See the{' '} + + installation guide + + . +
+
+ + {copyFeedback === 'copied' && Copied!} + {copyFeedback === 'error' && ( + Unable to copy. Copy manually from the docs. + )} +
+
+ )} +
+ )} - - +
diff --git a/web/src/utils/registryInstall.ts b/web/src/utils/registryInstall.ts new file mode 100644 index 0000000..26a242a --- /dev/null +++ b/web/src/utils/registryInstall.ts @@ -0,0 +1,427 @@ +import type { + InstallCommandPayload, + InstallInput, + InstallMode, + InstallTransport, +} from "../../../src/shared/types/rpcTypes"; +import type { + RegistryArgument, + RegistryPackage, + RegistryServer, + RegistryTransport, +} from "@/types/registry"; + +export type ProgramTarget = "vscode" | "claude"; + +export interface RegistryInstallBuildResult { + payload?: InstallCommandPayload; + missingInputs: InstallInput[]; + unavailableReason?: string; + transport?: InstallTransport; + mode: InstallMode; +} + +const PLACEHOLDER_REGEX = /\\?\${input:([^}]+)}/g; + +const sanitizeId = (value: string) => value.replace(/^--?/, "").replace(/[^a-zA-Z0-9_]+/g, "_"); + +function replacePlaceholders(value: string, resolver: (id: string) => string): string { + return value.replace(PLACEHOLDER_REGEX, (_, rawId) => { + const id = String(rawId ?? "").trim(); + if (!id) { + return ""; + } + return resolver(id); + }); +} + +function applyPlaceholderResolver( + payload: InstallCommandPayload, + resolver: (id: string) => string, +): InstallCommandPayload { + const mappedArgs = payload.args?.map((arg) => replacePlaceholders(arg, resolver)); + const mappedEnvEntries = payload.env ? Object.entries(payload.env) : []; + const mappedEnv = mappedEnvEntries.length + ? mappedEnvEntries.reduce>((acc, [key, value]) => { + acc[key] = replacePlaceholders(value, resolver); + return acc; + }, {}) + : undefined; + const mappedHeaders = payload.headers?.map((header) => ({ + name: header.name, + value: header.value !== undefined ? replacePlaceholders(header.value, resolver) : header.value, + })); + + return { + ...payload, + args: mappedArgs, + env: mappedEnv, + headers: mappedHeaders, + }; +} + +function buildClaudeConfig( + payload: InstallCommandPayload, + transport: InstallTransport, +): Record { + const config: Record = { type: transport }; + + if (transport === "stdio") { + if (payload.command) { + config.command = payload.command; + } + if (payload.args && payload.args.length > 0) { + config.args = payload.args; + } + if (payload.env && Object.keys(payload.env).length > 0) { + config.env = payload.env; + } + } else { + if (payload.url) { + config.url = payload.url; + } + if (payload.headers && payload.headers.length > 0) { + const headerMap = payload.headers.reduce>((acc, header) => { + if (header.name) { + acc[header.name] = header.value ?? ""; + } + return acc; + }, {}); + if (Object.keys(headerMap).length > 0) { + config.headers = headerMap; + } + } + } + + return config; +} + +function collectArgumentInputs(pkg: RegistryPackage | undefined) { + const args: string[] = []; + const inputs: InstallInput[] = []; + let positionalIndex = 0; + + const pushArgList = (list?: RegistryArgument[] | null) => { + if (!Array.isArray(list)) return; + for (const entry of list) { + const type = entry?.type; + const name = entry?.name; + const value = entry?.value; + const valueHint = entry?.valueHint ?? entry?.default; + const isSecret = Boolean(entry?.isSecret); + const description = entry?.description; + + if (type === "positional") { + if (value) { + args.push(value); + } else if (valueHint) { + args.push(valueHint); + } else { + const id = `arg_${positionalIndex++}`; + inputs.push({ type: "promptString", id, description: description || "Provide value", password: isSecret }); + args.push(`\\${input:${id}}`); + } + } else if (type === "named") { + if (name) { + args.push(name); + } + if (value) { + args.push(value); + } else if (valueHint) { + args.push(valueHint); + } else { + const base = name ? sanitizeId(name) : `arg_${positionalIndex++}`; + const id = `arg_${base}`; + inputs.push({ + type: "promptString", + id, + description: description || `Value for ${name || "argument"}`, + password: isSecret, + }); + args.push(`\\${input:${id}}`); + } + } + } + }; + + pushArgList(pkg?.runtimeArguments ?? undefined); + pushArgList(pkg?.packageArguments ?? undefined); + + return { args, inputs }; +} + +function collectEnvInputs(pkg: RegistryPackage | undefined) { + const env: Record = {}; + const inputs: InstallInput[] = []; + + if (Array.isArray(pkg?.environmentVariables)) { + for (const variable of pkg!.environmentVariables!) { + const key = variable?.name?.trim(); + if (!key) continue; + + const def = (variable?.value ?? variable?.default) || ""; + const description = variable?.description || undefined; + const isSecret = Boolean(variable?.isSecret); + + if (def) { + env[key] = def; + } else { + inputs.push({ type: "promptString", id: key, description, password: isSecret }); + env[key] = `\\${input:${key}}`; + } + } + } + + return { env, inputs }; +} + +function resolveRuntimeCommand(pkg: RegistryPackage | undefined) { + if (!pkg) { + return { unsupported: true as const }; + } + if (pkg.runtimeHint) { + return { command: pkg.runtimeHint }; + } + const type = (pkg.registryType || "").toLowerCase(); + if (type === "npm") { + return { command: "npx" }; + } + if (type === "pypi") { + return { command: "uvx" }; + } + if (type === "oci") { + return { command: "docker" }; + } + return { unsupported: true as const }; +} + +function ensureBaseArgs(command: string | undefined, pkg: RegistryPackage | undefined, args: string[]): string[] { + if (!command || !pkg?.identifier) { + return args; + } + + if (command === "npx") { + const spec = pkg.version ? `${pkg.identifier}@${pkg.version}` : pkg.identifier; + if (!args.some((arg) => typeof arg === "string" && arg.includes(pkg.identifier!))) { + return [spec, ...args]; + } + return args; + } + + if (command === "uvx") { + const spec = pkg.version && pkg.version !== "latest" ? `${pkg.identifier}==${pkg.version}` : pkg.identifier; + if (!args.some((arg) => typeof arg === "string" && arg.includes(pkg.identifier!))) { + return [spec, ...args]; + } + return args; + } + + return args; +} + +export function buildPackageInstall( + server: RegistryServer | undefined, + pkg: RegistryPackage | undefined, +): RegistryInstallBuildResult { + if (!pkg) { + return { + mode: "package", + missingInputs: [], + unavailableReason: "No package available", + }; + } + + const { command, unsupported } = resolveRuntimeCommand(pkg); + if (!command || unsupported) { + return { + mode: "package", + missingInputs: [], + unavailableReason: "Package is missing a supported runtime command", + }; + } + + const { args, inputs: argInputs } = collectArgumentInputs(pkg); + const { env, inputs: envInputs } = collectEnvInputs(pkg); + const combinedInputs = [...argInputs, ...envInputs]; + const argsWithBase = ensureBaseArgs(command, pkg, args); + + const payload: InstallCommandPayload = { + name: pkg.identifier || server?.name || "server", + command, + args: argsWithBase.length > 0 ? argsWithBase : undefined, + env: env && Object.keys(env).length > 0 ? env : undefined, + inputs: combinedInputs.length > 0 ? combinedInputs : undefined, + }; + + return { + mode: "package", + payload, + missingInputs: combinedInputs, + transport: "stdio", + }; +} + +function collectHeaderInputs(remote: RegistryTransport | undefined) { + const inputs: InstallInput[] = []; + const headers: Array<{ name: string; value: string }> = []; + + if (!remote?.headers || !Array.isArray(remote.headers)) { + return { inputs, headers }; + } + + const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + + for (const rawHeader of remote.headers) { + const name = rawHeader?.name?.trim(); + if (!name) continue; + + const description = rawHeader?.description || undefined; + const isSecret = Boolean(rawHeader?.isSecret); + const isRequired = Boolean(rawHeader?.isRequired); + const template = (rawHeader?.value ?? rawHeader?.default ?? "") as string; + const variables = rawHeader?.variables && typeof rawHeader.variables === "object" ? rawHeader.variables : undefined; + + const placeholders = new Set(); + if (variables) { + for (const key of Object.keys(variables)) { + if (key) { + placeholders.add(key); + } + } + } + + const placeholderPattern = /\{([^{}]+)\}|\$\{([^{}]+)\}/g; + let match: RegExpExecArray | null; + while ((match = placeholderPattern.exec(template)) !== null) { + const key = (match[1] ?? match[2])?.trim(); + if (key) { + placeholders.add(key); + } + } + + let value = template; + const ensureInput = (id: string, descriptionText?: string, password?: boolean) => { + if (!id) return; + if (!inputs.some((input) => input.id === id)) { + inputs.push({ type: "promptString", id, description: descriptionText, password }); + } + }; + + for (const id of placeholders) { + const variable = variables?.[id]; + const descriptionText = variable?.description || description; + const password = variable?.isSecret ?? isSecret; + ensureInput(id, descriptionText, password); + const escapedId = escapeRegExp(id); + value = value.replace(new RegExp(`\\{${escapedId}\\}`, "g"), `\\${input:${id}}`); + value = value.replace(new RegExp(`\\$\\{${escapedId}\\}`, "g"), `\\${input:${id}}`); + } + + if (!value && placeholders.size > 0) { + const [firstPlaceholder] = Array.from(placeholders); + if (firstPlaceholder) { + const variable = variables?.[firstPlaceholder]; + ensureInput(firstPlaceholder, variable?.description || description, variable?.isSecret ?? isSecret); + value = `\\${input:${firstPlaceholder}}`; + } + } + + const needsPrompt = + (isSecret && !value.includes("\\${input:")) || (!value && (isSecret || isRequired)); + + if (needsPrompt) { + const baseId = sanitizeId(name) || "value"; + const fallbackId = `header_${baseId}`; + ensureInput(fallbackId, description, isSecret); + value = `\\${input:${fallbackId}}`; + } + + headers.push({ name, value: value ?? "" }); + } + + return { inputs, headers }; +} + +export function buildRemoteInstall( + server: RegistryServer | undefined, + remote: RegistryTransport | undefined, +): RegistryInstallBuildResult { + if (!remote) { + return { + mode: "remote", + missingInputs: [], + unavailableReason: "No remote endpoint available", + }; + } + + const url = remote.url?.trim(); + if (!url) { + return { + mode: "remote", + missingInputs: [], + unavailableReason: "Remote endpoint is missing a URL", + }; + } + + const type = (remote.type || "http").toLowerCase(); + if (type && type !== "http" && type !== "sse") { + return { + mode: "remote", + missingInputs: [], + unavailableReason: `Remote transport '${remote.type}' is not supported`, + }; + } + + const transport: InstallTransport = type === "sse" ? "sse" : "http"; + const { inputs, headers } = collectHeaderInputs(remote); + + const payload: InstallCommandPayload = { + name: server?.name || "server", + url, + headers: headers.length > 0 ? headers : undefined, + inputs: inputs.length > 0 ? inputs : undefined, + }; + + return { + mode: "remote", + payload, + missingInputs: inputs, + transport, + }; +} + +export function createClaudeAddJsonCommand( + name: string, + transport: InstallTransport, + payload: InstallCommandPayload, +): string { + const substituted = applyPlaceholderResolver(payload, (id) => `<${id}>`); + const config = buildClaudeConfig(substituted, transport); + const json = JSON.stringify(config); + const escapedJson = json.replace(/'/g, "'\"'\"'"); + const needsQuoting = /\s/.test(name); + const nameArg = needsQuoting ? JSON.stringify(name) : name; + return `claude mcp add-json ${nameArg} '${escapedJson}'`; +} + +export async function copyClaudeCommand(command: string): Promise { + try { + if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") { + await navigator.clipboard.writeText(command); + return true; + } + } catch (error) { + console.warn("Failed to copy Claude command", error); + return false; + } + return false; +} + +export function substitutePlaceholdersForValues( + payload: InstallCommandPayload, + resolver: (id: string) => string, +): InstallCommandPayload { + return applyPlaceholderResolver(payload, resolver); +} + +export { PLACEHOLDER_REGEX as registryInstallPlaceholderRegex };