diff --git a/README.md b/README.md new file mode 100644 index 0000000..7fbf549 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# xdebug-mcp + +Serveur MCP pour analyser des fichiers de profiling Xdebug (format cachegrind). + +## Prérequis + +- Un fichier de profiling Xdebug (`.gz` ou texte brut) généré avec `xdebug.mode=profile` +- Optionnel : `make` + Go (pour la compilation locale) + +## Sommaire + +- [Prérequis](#prérequis) +- [À quoi sert ce serveur MCP ?](#à-quoi-sert-ce-serveur-mcp-) +- [Installation](#installation) +- [Utilisation avec un client MCP](#utilisation-avec-un-client-mcp) +- [Documentation](#documentation) + +## À quoi sert ce serveur MCP ? + +`xdebug-mcp` permet à un client MCP d'analyser des fichiers de profiling Xdebug sans outil externe : + +- analyse globale d'un profil : stats générales et top N fonctions par coût inclusif ; +- liste des appelants d'une fonction donnée, triés par coût décroissant ; +- liste des appelés d'une fonction donnée, triés par coût décroissant. + +## Installation + +### Option principale : script d'installation + +Installer la dernière release publique et lancer l'assistant de configuration : + +```sh +curl -fsSL https://get.lclr.dev/mcp/xdebug/install.sh | bash +``` + +### Option alternative : binaire de la dernière release + +Télécharger le binaire adapté à votre OS depuis la page des releases publiques : + +https://forge.lclr.dev/AI/xdebug-mcp/releases + +Puis le rendre exécutable (Linux/macOS) : + +```sh +chmod +x /absolute/path/to/xdebug-mcp +``` + +### Option alternative : compilation locale + +```sh +make build +``` + +Binaire généré : `build/xdebug-mcp` (ou `build/xdebug-mcp-linux-amd64` selon la cible). + +## Utilisation avec un client MCP + +### Claude Code CLI + +```sh +claude mcp add xdebug-mcp -- /absolute/path/to/xdebug-mcp mcp +``` + +### Configuration JSON + +```json +{ + "mcpServers": { + "xdebug-mcp": { + "command": "/absolute/path/to/xdebug-mcp", + "args": ["mcp"] + } + } +} +``` + +## Documentation + +- [Référence des outils MCP](docs/tools.md) diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..f0e3a68 --- /dev/null +++ b/install.sh @@ -0,0 +1,577 @@ +#!/usr/bin/env bash +set -euo pipefail + +BINARY_NAME="xdebug-mcp" +RELEASE_BASE_URL="https://forge.lclr.dev" +RELEASE_REPOSITORY="AI/xdebug-mcp" +INSTALLED_BINARY_PATH="" +PREFILL_SERVER_NAME="" +PREFILL_COMMAND_PATH="" + +if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then + C_RESET="$(printf '\033[0m')" + C_BOLD="$(printf '\033[1m')" + C_DIM="$(printf '\033[2m')" + C_RED="$(printf '\033[31m')" + C_GREEN="$(printf '\033[32m')" + C_YELLOW="$(printf '\033[33m')" + C_BLUE="$(printf '\033[34m')" + C_MAGENTA="$(printf '\033[35m')" + C_CYAN="$(printf '\033[36m')" +else + C_RESET="" + C_BOLD="" + C_DIM="" + C_RED="" + C_GREEN="" + C_YELLOW="" + C_BLUE="" + C_MAGENTA="" + C_CYAN="" +fi + +ui_line() { + printf "%b%s%b\n" "$C_DIM" "------------------------------------------------------------" "$C_RESET" >&2 +} + +ui_title() { + printf "\n%b%s%b\n" "$C_BOLD$C_CYAN" "$1" "$C_RESET" >&2 +} + +ui_info() { + printf "%b[info]%b %s\n" "$C_BLUE" "$C_RESET" "$1" >&2 +} + +ui_success() { + printf "%b[ok]%b %s\n" "$C_GREEN" "$C_RESET" "$1" >&2 +} + +ui_warn() { + printf "%b[warn]%b %s\n" "$C_YELLOW" "$C_RESET" "$1" >&2 +} + +ui_error() { + printf "%b[error]%b %s\n" "$C_RED" "$C_RESET" "$1" >&2 +} + +prompt() { + local label="$1" + local default_value="$2" + local answer="" + + if [ -n "$default_value" ]; then + printf "%b%s%b [%s]: " "$C_BOLD" "$label" "$C_RESET" "$default_value" >&2 + else + printf "%b%s%b: " "$C_BOLD" "$label" "$C_RESET" >&2 + fi + + if [ -t 2 ] && [ -r /dev/tty ]; then + if ! IFS= read -r answer < /dev/tty 2>/dev/null; then + IFS= read -r answer || answer="" + fi + else + IFS= read -r answer || answer="" + fi + + if [ -z "$answer" ]; then + printf "%s" "$default_value" + return + fi + + printf "%s" "$answer" +} + +tty_prompt_available() { + [ -t 2 ] && [ -r /dev/tty ] && [ -w /dev/tty ] +} + +menu_select() { + local title="$1" + shift + local options=("$@") + local count="${#options[@]}" + local index=0 + local key="" + local i=0 + local rows=$((count + 3)) + local rendered=0 + + if [ "$count" -eq 0 ]; then + return 1 + fi + + if ! tty_prompt_available; then + ui_title "$title" + i=0 + while [ "$i" -lt "$count" ]; do + printf " %d) %s\n" "$((i + 1))" "${options[$i]}" >&2 + i=$((i + 1)) + done + + while true; do + local raw_choice + raw_choice="$(prompt "Choix" "1")" + case "$raw_choice" in + ''|*[!0-9]*) + ui_warn "Choix invalide: $raw_choice" + ;; + *) + if [ "$raw_choice" -ge 1 ] && [ "$raw_choice" -le "$count" ]; then + printf "%s" "${options[$((raw_choice - 1))]}" + return 0 + fi + ui_warn "Choix invalide: $raw_choice" + ;; + esac + done + fi + + while true; do + if [ "$rendered" -eq 1 ]; then + printf "\033[%dA\033[J" "$rows" >&2 2>/dev/null || true + fi + ui_title "$title" + i=0 + while [ "$i" -lt "$count" ]; do + if [ "$i" -eq "$index" ]; then + printf " %b› %s%b\n" "$C_BOLD$C_CYAN" "${options[$i]}" "$C_RESET" >&2 + else + printf " %s\n" "${options[$i]}" >&2 + fi + i=$((i + 1)) + done + printf "%bUtilise ↑/↓ puis Entrée.%b\n" "$C_DIM" "$C_RESET" >&2 + rendered=1 + + if ! IFS= read -rsn1 key < /dev/tty; then + continue + fi + + case "$key" in + "") + printf "%s" "${options[$index]}" + return 0 + ;; + $'\x1b') + if IFS= read -rsn2 key < /dev/tty; then + case "$key" in + "[A") + if [ "$index" -eq 0 ]; then + index=$((count - 1)) + else + index=$((index - 1)) + fi + ;; + "[B") + if [ "$index" -eq $((count - 1)) ]; then + index=0 + else + index=$((index + 1)) + fi + ;; + esac + fi + ;; + esac + done +} + +sanitize_server_name() { + local raw="$1" + local sanitized + sanitized="$(printf "%s" "$raw" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]/-/g; s/--*/-/g; s/^-*//; s/-*$//')" + if [ -z "$sanitized" ]; then + sanitized="$BINARY_NAME" + fi + printf "%s" "$sanitized" +} + +toml_escape() { + printf "%s" "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +go_bin_dir() { + local gobin + gobin="$(go env GOBIN 2>/dev/null || true)" + if [ -n "$gobin" ]; then + printf "%s\n" "$gobin" + return + fi + + go env GOPATH 2>/dev/null | awk '{print $1 "/bin"}' +} + +resolve_binary_path() { + if [ -n "$INSTALLED_BINARY_PATH" ] && [ -x "$INSTALLED_BINARY_PATH" ]; then + printf "%s\n" "$INSTALLED_BINARY_PATH" + return + fi + + if command -v "$BINARY_NAME" >/dev/null 2>&1; then + command -v "$BINARY_NAME" + return + fi + + if command -v go >/dev/null 2>&1; then + local bin_dir + bin_dir="$(go_bin_dir)" + if [ -n "$bin_dir" ] && [ -x "$bin_dir/$BINARY_NAME" ]; then + printf "%s\n" "$bin_dir/$BINARY_NAME" + return + fi + fi + + printf "%s\n" "$HOME/.local/bin/$BINARY_NAME" +} + +ensure_cli() { + local cli_name="$1" + if command -v "$cli_name" >/dev/null 2>&1; then + return + fi + + ui_error "Commande introuvable: $cli_name" + exit 1 +} + +detect_os() { + case "$(uname -s | tr '[:upper:]' '[:lower:]')" in + linux) printf "linux" ;; + darwin) printf "darwin" ;; + *) + ui_error "OS non supporte: $(uname -s)" + exit 1 + ;; + esac +} + +detect_arch() { + case "$(uname -m)" in + x86_64|amd64) printf "amd64" ;; + aarch64|arm64) printf "arm64" ;; + *) + ui_error "Architecture non supportee: $(uname -m)" + exit 1 + ;; + esac +} + +release_api_url() { + printf "%s/api/v1/repos/%s/releases/latest\n" "${RELEASE_BASE_URL%/}" "$RELEASE_REPOSITORY" +} + +extract_asset_url() { + local json="$1" + local asset_name="$2" + + printf "%s\n" "$json" \ + | grep -o '"browser_download_url":"[^"]*"' \ + | sed 's/"browser_download_url":"//;s/"$//' \ + | sed 's#\\/#/#g' \ + | grep "/${asset_name}$" \ + | head -n 1 +} + +verify_checksum() { + local binary_path="$1" + local checksum_path="$2" + + if command -v sha256sum >/dev/null 2>&1; then + ( cd "$(dirname "$binary_path")" && sha256sum -c "$(basename "$checksum_path")" ) + return + fi + + if command -v shasum >/dev/null 2>&1; then + local expected + local actual + expected="$(awk '{print $1}' "$checksum_path")" + actual="$(shasum -a 256 "$binary_path" | awk '{print $1}')" + if [ "$expected" != "$actual" ]; then + ui_error "Checksum invalide pour $binary_path" + exit 1 + fi + return + fi + + ui_warn "Aucun utilitaire checksum trouve (sha256sum/shasum), verification ignoree." +} + +download_latest_release_binary() { + if ! command -v curl >/dev/null 2>&1; then + ui_error "curl est requis pour telecharger la release." + exit 1 + fi + + local os_name + local arch_name + local ext="" + os_name="$(detect_os)" + arch_name="$(detect_arch)" + if [ "$os_name" = "windows" ]; then + ext=".exe" + fi + + local asset_name + local checksum_name + asset_name="${BINARY_NAME}-${os_name}-${arch_name}${ext}" + checksum_name="${asset_name}.sha256" + + ui_info "Recuperation de la derniere release..." + local release_json + release_json="$(curl -fsSL "$(release_api_url)")" + + local asset_url + asset_url="$(extract_asset_url "$release_json" "$asset_name")" + if [ -z "$asset_url" ]; then + ui_error "Asset introuvable dans la derniere release: $asset_name" + exit 1 + fi + + local checksum_url + checksum_url="$(extract_asset_url "$release_json" "$checksum_name" || true)" + + local tmp_dir + tmp_dir="$(mktemp -d)" + trap 'if [ -n "${tmp_dir:-}" ] && [ -d "${tmp_dir:-}" ]; then rm -rf "${tmp_dir}"; fi' EXIT INT TERM + + curl -fsSL "$asset_url" -o "$tmp_dir/$asset_name" + chmod +x "$tmp_dir/$asset_name" + + if [ -n "$checksum_url" ]; then + curl -fsSL "$checksum_url" -o "$tmp_dir/$checksum_name" + verify_checksum "$tmp_dir/$asset_name" "$tmp_dir/$checksum_name" + fi + + local target_dir + target_dir="$(prompt "Repertoire d'installation" "$HOME/.local/bin")" + mkdir -p "$target_dir" + install -m 0755 "$tmp_dir/$asset_name" "$target_dir/$BINARY_NAME" + INSTALLED_BINARY_PATH="$target_dir/$BINARY_NAME" + + ui_success "Binaire installe: $INSTALLED_BINARY_PATH" + ui_info "Ajoute ce dossier au PATH si necessaire." +} + +install_binary() { + if command -v "$BINARY_NAME" >/dev/null 2>&1; then + INSTALLED_BINARY_PATH="$(command -v "$BINARY_NAME")" + ui_success "Binaire detecte: $INSTALLED_BINARY_PATH" + local reinstall + reinstall="$(prompt "Forcer une reinstall depuis la derniere release ? (y/N)" "N")" + case "$reinstall" in + y|Y|yes|YES) + download_latest_release_binary + return + ;; + *) + return + ;; + esac + fi + + download_latest_release_binary +} + +collect_server_inputs() { + local default_name + default_name="$(sanitize_server_name "${PREFILL_SERVER_NAME:-$BINARY_NAME}")" + SERVER_NAME="$(prompt "Nom du serveur MCP" "$default_name")" + SERVER_NAME="$(sanitize_server_name "$SERVER_NAME")" + + local default_command + if [ -n "${PREFILL_COMMAND_PATH:-}" ]; then + default_command="$PREFILL_COMMAND_PATH" + else + default_command="$(resolve_binary_path)" + fi + COMMAND_PATH="$(prompt "Chemin du binaire serveur MCP" "$default_command")" +} + +choose_scope() { + local selected + selected="$(menu_select "Scope de configuration" "global (user)" "project (projet courant)")" + case "$selected" in + "global (user)") + printf "global" + ;; + *) + printf "project" + ;; + esac +} + +apply_claude_mcp() { + ensure_cli "claude" + collect_server_inputs + + local scope_choice + scope_choice="$(choose_scope)" + local claude_scope + if [ "$scope_choice" = "global" ]; then + claude_scope="user" + else + claude_scope="project" + fi + + ui_info "Application de la configuration Claude ($claude_scope)..." + claude mcp remove --scope "$claude_scope" "$SERVER_NAME" >/dev/null 2>&1 || true + claude mcp add \ + --transport stdio \ + --scope "$claude_scope" \ + "$SERVER_NAME" \ + -- "$COMMAND_PATH" mcp + + ui_success "Serveur \"$SERVER_NAME\" configure dans Claude ($claude_scope)." +} + +rewrite_codex_project_config() { + local project_dir="$1" + local config_file="$project_dir/.codex/config.toml" + local section_prefix="[mcp_servers.${SERVER_NAME}" + + mkdir -p "$project_dir/.codex" + touch "$config_file" + + local tmp_file + tmp_file="$(mktemp)" + + awk -v prefix="$section_prefix" ' + function is_target(line, p, next_char) { + if (index(line, p) != 1) { + return 0 + } + next_char = substr(line, length(p) + 1, 1) + return next_char == "]" || next_char == "." + } + /^\[.*\]$/ { + if (is_target($0, prefix)) { + skip = 1 + next + } + if (skip == 1) { + skip = 0 + } + } + { + if (skip != 1) { + print $0 + } + } + ' "$config_file" > "$tmp_file" + + mv "$tmp_file" "$config_file" + + { + printf "\n[mcp_servers.%s]\n" "$SERVER_NAME" + printf "command = \"%s\"\n" "$(toml_escape "$COMMAND_PATH")" + printf "args = [\"mcp\"]\n" + } >> "$config_file" +} + +apply_codex_mcp() { + ensure_cli "codex" + collect_server_inputs + + local scope_choice + scope_choice="$(choose_scope)" + + if [ "$scope_choice" = "global" ]; then + ui_info "Application via codex mcp add (scope global)..." + codex mcp remove "$SERVER_NAME" >/dev/null 2>&1 || true + codex mcp add \ + "$SERVER_NAME" \ + -- "$COMMAND_PATH" mcp + ui_success "Serveur \"$SERVER_NAME\" configure dans le scope global Codex." + return + fi + + local default_project_dir + default_project_dir="$(pwd)" + local project_dir + project_dir="$(prompt "Dossier projet cible pour .codex/config.toml" "$default_project_dir")" + rewrite_codex_project_config "$project_dir" + ui_success "Configuration projet ecrite dans $project_dir/.codex/config.toml" +} + +print_mcp_json() { + collect_server_inputs + cat <&2 + printf "%bRelease repo:%b %s\n" "$C_DIM" "$C_RESET" "$RELEASE_REPOSITORY" >&2 + ui_line + printf "%bSelectionne une action dans le menu interactif.%b\n" "$C_DIM" "$C_RESET" >&2 +} + +main() { + print_header + + local action + action="$(menu_select \ + "Choisis une action" \ + "Installer/mettre a jour le binaire" \ + "Configurer Claude Code (apply direct)" \ + "Configurer Codex (apply direct)" \ + "Generer JSON MCP manuel" \ + "Quitter")" + printf "\n" >&2 + + case "$action" in + "Installer/mettre a jour le binaire") + install_binary + PREFILL_SERVER_NAME="$(sanitize_server_name "$BINARY_NAME")" + PREFILL_COMMAND_PATH="$(resolve_binary_path)" + ui_title "Configuration MCP apres installation" + local next_action + next_action="$(menu_select \ + "Configurer le MCP maintenant ?" \ + "Configurer Claude Code (apply direct)" \ + "Configurer Codex (apply direct)" \ + "Generer JSON MCP manuel" \ + "Terminer sans config MCP")" + printf "\n" >&2 + case "$next_action" in + "Configurer Claude Code (apply direct)") + apply_claude_mcp + ;; + "Configurer Codex (apply direct)") + apply_codex_mcp + ;; + "Generer JSON MCP manuel") + ui_info "JSON MCP genere sur stdout." + print_mcp_json + ;; + *) + ui_info "Installation terminee sans configuration MCP additionnelle." + ;; + esac + ;; + "Configurer Claude Code (apply direct)") + apply_claude_mcp + ;; + "Configurer Codex (apply direct)") + apply_codex_mcp + ;; + "Generer JSON MCP manuel") + ui_info "JSON MCP genere sur stdout." + print_mcp_json + ;; + *) + ui_warn "Annule." + ;; + esac +} + +main "$@"