Cap 24: Claude Code en CI/CD y Tool Design

Por: Artiko
claude-codecicdgithub-actionsgitlab-cimcptool-designcertificacion

Claude Code en CI/CD y Tool Design

Este tutorial cubre dos dominios del examen Claude Certified Architect – Foundations:

  • Domain 3: Claude Code en pipelines CI/CD
  • Domain 2: Diseño de herramientas (Tool Design)

1. Modo No-Interactivo

Claude Code fue diseñado para correr en terminales interactivas, pero en CI/CD necesitamos que opere de forma autónoma.

Flag -p (—print)

El flag -p activa el modo no-interactivo: Claude ejecuta el prompt y termina. No espera input del usuario.

# Modo interactivo (por defecto, espera input)
claude "revisa este código"

# Modo no-interactivo: ejecuta y termina
claude -p "revisa este código y reporta issues"

Output formats

Por defecto, Claude imprime texto en formato legible para humanos. En CI necesitamos machine-parseable:

# JSON estructurado para parsear con jq o scripts
claude -p "analiza el PR" --output-format json

# Forzar un schema específico en el JSON de respuesta
claude -p "analiza el PR" \
  --output-format json \
  --json-schema '{"type":"object","properties":{"issues":{"type":"array"},"severity":{"type":"string"}}}'

Con --json-schema, la salida validada contra el schema llega en el campo structured_output del JSON de resultado. No la busques en el texto libre de la respuesta.

OUTPUT=$(claude -p "analiza el PR" --output-format json --json-schema "$SCHEMA")
ISSUES=$(echo "$OUTPUT" | jq '.structured_output.issues')

Control de coste por ejecución

En pipelines automáticos conviene poner un techo de gasto explícito por corrida:

claude -p "analiza el PR" \
  --output-format json \
  --max-budget-usd 1.50

Flag --bare: el modo recomendado para CI/SDK

--bare omite hooks, skills, plugins, servidores MCP, auto memory y CLAUDE.md. Es la configuración recomendada para CI y para el SDK, y será el default de -p en el futuro.

claude --bare -p "revisa este diff y responde en JSON" --output-format json

Ventajas en CI: arranque determinista, sin dependencias de configuración local del runner ni de extensiones que puedan cambiar el comportamiento entre corridas.

Incompatibilidades a tener presentes en este mismo capítulo:

  • El patrón de la sección 5 (un CLAUDE.md específico para CI) no funciona con --bare: ese archivo no se carga.
  • El patrón de la sección 11 (servidores MCP en el pipeline) tampoco funciona con --bare: MCP queda desactivado.

Es decir: o pipeline mínimo y determinista con --bare, o pipeline con contexto/MCP sin --bare. No ambas cosas a la vez.

Exit codes y límites de proceso

Claude Code usa exit codes estándar en modo -p:

Exit codeSignificado
0Éxito
1Error de ejecución
2Error de configuración / API key inválida
143Terminado por SIGTERM (timeout del job, cancelación del pipeline)

Estos exit codes permiten a tu pipeline tomar decisiones con if [ $? -ne 0 ]. El 143 es el que verás cuando el runner cancela el job o se agota el timeout: distinguirlo evita reportar un “fallo de review” que en realidad fue una cancelación.

Límite de stdin: desde v2.1.128 el stdin por pipe está limitado a 10MB. Es una restricción directa sobre el patrón de este capítulo (pipear diffs completos): trunca el diff antes de pasarlo.

# Truncar el diff para no superar el límite de stdin
git diff origin/main...HEAD | head -c 8000000 | \
  claude --bare -p "revisa este diff" --output-format json

Permisos en headless

En CI no hay nadie para aprobar prompts, así que el control de permisos es parte de la configuración del pipeline, no un detalle opcional.

--allowedTools acepta la sintaxis de reglas de permisos. Ojo con el espacio: en Bash(git diff *) el espacio antes del * impone frontera de palabra.

claude -p "revisa el diff y reporta issues" \
  --output-format json \
  --allowedTools "Read,Bash(git diff *),Bash(git log *)"

Reglas relevantes al escribirlas:

  • Bash(ls *) matchea ls -la pero no lsof; Bash(ls*) matchea ambos.
  • Bash(safe-cmd *) no autoriza safe-cmd && other-cmd: cada subcomando separado por &&, ||, ;, | o & se evalúa por separado.
  • Un deny en cualquier scope de settings bloquea un allow, incluido lo que pases en --allowedTools.

Modo auto para pipelines: un clasificador aparte revisa cada acción antes de ejecutarla.

claude --permission-mode auto -p "fix all lint errors"

Advertencia operativa: con -p los bloqueos repetidos del clasificador abortan la sesión, porque no hay usuario a quien devolverle el prompt. Si tu pipeline pide acciones que el clasificador considera escaladas de alcance, la corrida termina en fallo — no en una pausa.


2. Session Isolation: Por qué importa en CI/CD

Un principio crítico: Claude no puede revisar efectivamente sus propios cambios en la misma sesión.

El problema

Cuando Claude genera código en una sesión y luego se le pide que lo revise, el modelo tiene el contexto completo de su propio razonamiento. Esto crea un bias de confirmación: tiende a validar lo que generó en lugar de detectar errores.

sequenceDiagram
    participant CI as CI Pipeline
    participant S1 as Sesión 1 (Generate)
    participant S2 as Sesión 2 (Review)
    participant Repo as Repositorio

    CI->>S1: "Genera tests para auth.ts"
    S1->>Repo: Escribe tests/auth.test.ts
    CI->>S2: "Revisa tests/auth.test.ts (sin contexto previo)"
    S2->>CI: Review objetivo con issues reales

La solución: sesión nueva por cada review

Cada paso del pipeline debe iniciar una sesión completamente nueva. Nunca reutilices una sesión para generar Y revisar.

flowchart LR
    A[PR abierto] --> B[Sesión 1: Generar review]
    B --> C[Parsear JSON output]
    C --> D{Issues críticos?}
    D -->|Sí| E[Fail pipeline]
    D -->|No| F[Comentar en PR]
    F --> G[Merge permitido]

3. GitHub Actions: Code Review Automático

Workflow completo para revisar PRs automáticamente con Claude Code.

# .github/workflows/claude-review.yml
name: Claude Code Review

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  review:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
      contents: read

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Install Claude Code
        run: |
          curl -fsSL https://claude.ai/install.sh | bash
          echo "$HOME/.local/bin" >> $GITHUB_PATH

      - name: Get changed files
        id: changed
        run: |
          FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | \
            grep -E '\.(ts|tsx|js|jsx|py|go|rs)$' | \
            head -20)
          echo "files=$FILES" >> $GITHUB_OUTPUT

      - name: Run Claude Review
        id: review
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: |
          DIFF=$(git diff origin/${{ github.base_ref }}...HEAD -- ${{ steps.changed.outputs.files }})

          OUTPUT=$(claude -p \
            --output-format json \
            "Revisa este diff de código. Responde en JSON con: {\"summary\": string, \"issues\": [{\"file\": string, \"line\": number, \"severity\": \"critical|warning|info\", \"message\": string}], \"approved\": boolean}

          DIFF:
          $DIFF" 2>&1)

          echo "output=$OUTPUT" >> $GITHUB_OUTPUT

          # Fallar si hay issues críticos
          CRITICAL=$(echo "$OUTPUT" | jq '[.issues[] | select(.severity == "critical")] | length')
          if [ "$CRITICAL" -gt "0" ]; then
            echo "❌ Se encontraron $CRITICAL issues críticos"
            exit 1
          fi

      - name: Comment on PR
        if: always()
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          OUTPUT='${{ steps.review.outputs.output }}'
          SUMMARY=$(echo "$OUTPUT" | jq -r '.summary // "Sin resumen"')
          ISSUES=$(echo "$OUTPUT" | jq -r '.issues[] | "- **\(.severity)** `\(.file):\(.line)` — \(.message)"' 2>/dev/null || echo "Sin issues")

          COMMENT="## 🤖 Claude Code Review

          $SUMMARY

          ### Issues encontrados

          $ISSUES"

          gh pr comment ${{ github.event.pull_request.number }} \
            --body "$COMMENT"

Notas importantes del workflow

  1. fetch-depth: 0 es necesario para que git diff funcione correctamente entre branches.
  2. permissions: pull-requests: write permite que el workflow comente en el PR.
  3. El output de Claude se parsea con jq — por eso necesitamos --output-format json.
  4. head -20 limita los archivos a revisar para no exceder el context window.

Métodos de instalación en CI

El instalador nativo (install.sh en macOS/Linux/WSL, install.ps1 en PowerShell, install.cmd en CMD) es el método recomendado y se auto-actualiza en segundo plano.

Alternativas oficiales según la imagen del runner:

MétodoComando
Instalador nativo (recomendado)curl -fsSL https://claude.ai/install.sh | bash
Homebrew caskbrew install --cask claude-code
WinGetwinget install Anthropic.ClaudeCode
Repos firmados apt/dnf/apkpaquete claude-code, canales stable o latest
npm (opción avanzada)npm install -g @anthropic-ai/claude-code

npm install -g @anthropic-ai/claude-code sigue existiendo, pero ya no es el método principal: quedó como opción avanzada. Los instaladores de paquetes (brew, winget, apt/dnf/apk) no se auto-actualizan salvo CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1 en brew/winget — en CI eso es deseable: fija la versión y evita cambios de comportamiento entre corridas.

Advertencia de seguridad: contenido no confiable en el pipeline

Este workflow le da a Claude el diff de un PR, que es contenido escrito por terceros. Dos hechos que cambian el modelo de amenaza respecto de una sesión interactiva:

  1. Con -p la verificación de trust está desactivada. La confirmación de confianza de codebases y de servidores MCP nuevos no aparece en headless: un pipeline CI no conserva las mismas barreras de confianza que una sesión interactiva.
  2. El diff puede contener prompt injection. Un comentario, un README o un archivo del PR pueden traer instrucciones dirigidas al agente. La documentación oficial lo enuncia así: el comportamiento del modelo puede verse influido por el contenido que procesa, y conviene evitar pipear contenido no confiable directamente a Claude.

Mitigaciones aplicables a estos pipelines:

  • Aislamiento: correr el job en un contenedor efímero, sin credenciales de producción montadas.
  • Mínimo privilegio: permissions: del workflow acotado (contents: read), tokens de solo lectura donde sea posible.
  • --bare para no exponer hooks, skills, plugins ni MCP a una sesión que procesa contenido externo.
  • Allowlists estrictas con --allowedTools, en vez de dejar el set completo de herramientas disponible.
claude --bare -p "$PROMPT" \
  --output-format json \
  --allowedTools "Read" \
  --max-budget-usd 1.00

Ningún sistema es completamente inmune: la defensa es en profundidad, no confianza en que el modelo ignore las instrucciones hostiles.


4. GitLab CI: Equivalente

# .gitlab-ci.yml
stages:
  - review
  - test

variables:
  ANTHROPIC_API_KEY: $ANTHROPIC_API_KEY  # Configurar en GitLab CI/CD Variables

claude-review:
  stage: review
  image: alpine:3.20
  only:
    - merge_requests
  script:
    - apk add --no-cache curl git jq bash
    # Instalador nativo (alternativa: paquete del repo firmado apk/apt/dnf en la imagen del runner)
    - curl -fsSL https://claude.ai/install.sh | bash
    - export PATH="$HOME/.local/bin:$PATH"
    - |
      DIFF=$(git diff origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME...HEAD)

      claude -p \
        --output-format json \
        "Revisa este diff. JSON: {\"issues\": [{\"severity\": \"critical|warning\", \"message\": string}], \"approved\": boolean}

      DIFF: $DIFF" > review-output.json

      CRITICAL=$(jq '[.issues[] | select(.severity == "critical")] | length' review-output.json)
      if [ "$CRITICAL" -gt "0" ]; then
        echo "Issues críticos encontrados:"
        jq '.issues[] | select(.severity == "critical")' review-output.json
        exit 1
      fi
  artifacts:
    paths:
      - review-output.json
    expire_in: 7 days
    when: always
  allow_failure: false

Variables de entorno en GitLab

Configurar en Settings → CI/CD → Variables:

VariableTipoDescripción
ANTHROPIC_API_KEYSecretAPI key de Anthropic
MAX_MCP_OUTPUT_TOKENSVariableLímite de salida de las herramientas MCP (default 25.000 tokens)
MCP_CLIENT_SECRETSecretClient secret preconfigurado para OAuth de servidores MCP en CI

Para acotar el gasto por corrida no hay variable de entorno: se usa el flag --max-budget-usd en la invocación (ver sección 1). No existe una variable CLAUDE_MAX_TOKENS en Claude Code.


5. CLAUDE.md para CI/CD

Cuando Claude corre en CI, el CLAUDE.md debe ser más explícito que para uso interactivo. Claude no puede pedirte aclaraciones.

Requisito: este patrón exige no usar --bare, porque esa bandera omite CLAUDE.md (además de hooks, skills, plugins, MCP y auto memory). Si quieres el determinismo de --bare, mueve estas instrucciones al prompt o a --append-system-prompt en lugar de a un archivo.

Qué incluir en CLAUDE.md para CI

# CLAUDE.md — CI/CD Configuration

## Modo de operación
Estás corriendo en un pipeline CI/CD automatizado. No hay usuario presente.
Nunca pidas aclaraciones. Si falta información, usa valores por defecto documentados aquí.

## Output format requerido
SIEMPRE responde en JSON válido con esta estructura exacta:
{
  "summary": "string — resumen de máximo 200 caracteres",
  "issues": [
    {
      "file": "ruta/relativa/al/archivo.ts",
      "line": 42,
      "severity": "critical | warning | info",
      "category": "security | performance | correctness | style",
      "message": "descripción del issue"
    }
  ],
  "approved": true | false,
  "metrics": {
    "files_reviewed": 0,
    "total_issues": 0
  }
}

## Criterios de review

### Critical (falla el pipeline):
- SQL injection o XSS potencial
- Secrets hardcodeados (API keys, passwords)
- Funciones async sin manejo de errores
- Imports de módulos inexistentes

### Warning (comentario, no falla):
- Variables declaradas pero no usadas
- Console.log en código de producción
- Funciones con más de 50 líneas

### Info (informativo):
- Sugerencias de refactoring
- Oportunidades de optimización

## Testing standards
- Framework: Vitest
- Fixtures disponibles en: tests/fixtures/
- Mocks en: tests/mocks/
- Cobertura mínima: 80%

## Umbrales que fallan el pipeline
- Más de 0 issues de tipo "critical"
- Más de 10 issues de tipo "warning"
- Cobertura de tests por debajo del 80%

6. Generación de Tests en CI

Patrón para generar tests automáticamente cuando se agregan nuevas funciones:

flowchart TD
    A[Nueva función detectada en PR] --> B[Sesión: Generar tests]
    B --> C[Guardar tests generados]
    C --> D[Ejecutar tests]
    D --> E{Tests pasan?}
    E -->|Sí| F[Commit tests al PR]
    E -->|No| G[Sesión: Corregir tests]
    G --> D
    E -->|Fallo 3+ veces| H[Fallar pipeline con reporte]

Script de generación de tests

#!/bin/bash
# scripts/generate-tests.sh

MAX_RETRIES=3
RETRY=0

generate_and_run() {
  local source_file=$1
  local test_file="${source_file%.ts}.test.ts"

  # Sesión nueva: generar tests
  claude -p \
    --output-format json \
    "Lee el archivo $source_file y genera tests completos en Vitest.
    Responde con JSON: {\"test_code\": \"...\", \"imports_needed\": [\"...\"]}.
    Los tests deben ser ejecutables sin modificaciones.
    Usa los fixtures en tests/fixtures/ si aplica.
    $(cat $source_file)" > /tmp/test-output.json

  TEST_CODE=$(jq -r '.test_code' /tmp/test-output.json)
  echo "$TEST_CODE" > "$test_file"

  # Ejecutar tests
  if npx vitest run "$test_file" --reporter=json > /tmp/test-results.json 2>&1; then
    echo "✅ Tests generados y pasando: $test_file"
    return 0
  else
    return 1
  fi
}

SOURCE_FILE=$1
while [ $RETRY -lt $MAX_RETRIES ]; do
  if generate_and_run "$SOURCE_FILE"; then
    exit 0
  fi
  RETRY=$((RETRY + 1))
  echo "Intento $RETRY/$MAX_RETRIES falló"
done

echo "❌ No se pudieron generar tests válidos después de $MAX_RETRIES intentos"
exit 1

7. Tool Design — Domain 2

Esta sección es crítica para el examen. El diseño de herramientas determina si Claude las usará correctamente.

El problema con descripciones vagas

Claude selecciona herramientas basándose en sus descripciones. Una descripción vaga produce selecciones incorrectas.

MALO:

{
  "name": "analyze_content",
  "description": "Analyzes content",
  "input_schema": {
    "type": "object",
    "properties": {
      "data": { "type": "string" }
    }
  }
}

Problemas: ¿Qué tipo de contenido? ¿Qué devuelve? ¿Cuándo NO usar esta herramienta?

BUENO:

{
  "name": "analyze_invoice_pdf",
  "description": "Extrae datos estructurados de facturas en formato PDF. Input: PDF codificado en base64. Devuelve: vendor_name, total_amount, line_items[], invoice_date, currency. Úsala cuando el usuario suba una factura para procesar. NO la uses para páginas web, imágenes de texto genéricas o documentos que no sean facturas.",
  "input_schema": {
    "type": "object",
    "properties": {
      "pdf_base64": {
        "type": "string",
        "description": "PDF codificado en base64. Máximo 10MB."
      },
      "extract_line_items": {
        "type": "boolean",
        "description": "Si true, extrae cada línea de la factura. Default: true."
      }
    },
    "required": ["pdf_base64"]
  }
}

Reglas para descripciones efectivas

Toda buena descripción de herramienta incluye:

  1. Qué hace exactamente — verbo concreto, no genérico
  2. Formato exacto del input — tipo, encoding, límites
  3. Formato exacto del output — campos, tipos, valores posibles
  4. Cuándo USAR esta herramienta — trigger conditions
  5. Cuándo NO USAR esta herramienta — boundaries explícitos
  6. Ejemplos de queries que deben rutear a esta herramienta

Comparación completa

// MALO: Claude no sabe cómo usarla
const badTool = {
  name: "search",
  description: "Searches for information",
  input_schema: {
    type: "object",
    properties: {
      query: { type: "string" }
    }
  }
}

// BUENO: Claude selecciona correctamente
const goodTool = {
  name: "search_product_catalog",
  description: `Busca productos en el catálogo interno por nombre, SKU o categoría.
Devuelve: [{id, name, sku, price, stock, category}].
Úsala cuando el usuario pregunte por disponibilidad, precio o características de productos.
NO la uses para buscar información general, noticias o datos externos.
Queries que van aquí: "¿tienen el modelo X?", "precio de SKU-123", "productos de la categoría Y".`,
  input_schema: {
    type: "object",
    properties: {
      query: {
        type: "string",
        description: "Término de búsqueda: nombre parcial, SKU completo, o nombre de categoría"
      },
      max_results: {
        type: "number",
        description: "Máximo de resultados. Default: 10, máximo: 50."
      }
    },
    required: ["query"]
  }
}

8. Error Responses Estructuradas en MCP Tools

Las herramientas MCP deben devolver errores que permitan a Claude decidir si reintentar o no.

Estructura recomendada

interface ToolError {
  isError: true;
  errorCategory: "transient" | "validation" | "business" | "permission";
  isRetryable: boolean;
  message: string;
  retryAfterMs?: number;  // Solo si isRetryable: true
}

Cuándo usar cada categoría

// TRANSIENT: error temporal, Claude debe reintentar
function handleTimeout(): ToolError {
  return {
    isError: true,
    errorCategory: "transient",
    isRetryable: true,
    message: "El servicio no respondió a tiempo. Reintenta en unos segundos.",
    retryAfterMs: 3000
  };
}

// VALIDATION: input incorrecto, Claude debe corregir el input
function handleInvalidInput(field: string): ToolError {
  return {
    isError: true,
    errorCategory: "validation",
    isRetryable: false,
    message: `El campo '${field}' tiene formato inválido. Formato esperado: YYYY-MM-DD.`
  };
}

// BUSINESS: dato genuinamente ausente, NO reintentar
function handleNotFound(id: string): ToolError {
  return {
    isError: true,
    errorCategory: "business",
    isRetryable: false,
    message: `El producto con ID '${id}' no existe en el catálogo.`
  };
}

// PERMISSION: sin acceso, NO reintentar
function handleUnauthorized(): ToolError {
  return {
    isError: true,
    errorCategory: "permission",
    isRetryable: false,
    message: "Sin permisos para acceder a datos financieros. Contacta al administrador."
  };
}

Distinción crítica: timeout vs vacío válido

// ❌ INCORRECTO: reintentar cuando el resultado es vacío válido
async function searchProducts_bad(query: string) {
  const results = await db.search(query);
  if (results.length === 0) {
    return {
      isError: true,
      errorCategory: "transient",
      isRetryable: true,  // ← INCORRECTO: no hay productos, no es un error
      message: "No se encontraron resultados"
    };
  }
  return results;
}

// ✅ CORRECTO: vacío es un resultado válido
async function searchProducts_good(query: string) {
  try {
    const results = await db.search(query);
    return { results, total: results.length };  // Devolver array vacío es válido
  } catch (err) {
    if (err.code === 'TIMEOUT') {
      return {
        isError: true,
        errorCategory: "transient",
        isRetryable: true,
        message: "Base de datos no disponible temporalmente."
      };
    }
    throw err;
  }
}

9. Distribución de Herramientas entre Agentes

Un principio fundamental: demasiadas herramientas disminuye la confiabilidad.

Por qué menos herramientas es mejor

Cuando un agente tiene acceso a herramientas fuera de su especialización:

  • Selecciona herramientas incorrectas para tareas ambiguas
  • Produce resultados inconsistentes
  • Es más difícil de debuggear y testear
flowchart TD
    subgraph Incorrecto["❌ Incorrecto: Todas las tools en todos los agentes"]
        A1[Research Agent] --> T1[web_search]
        A1 --> T2[write_file]
        A1 --> T3[execute_sql]
        A1 --> T4[send_email]
        A2[Synthesis Agent] --> T1
        A2 --> T2
        A2 --> T3
        A2 --> T4
    end

    subgraph Correcto["✅ Correcto: Tools especializadas por agente"]
        B1[Research Agent] --> U1[web_search]
        B1 --> U2[fetch_url]
        B2[Synthesis Agent] --> U3[write_file]
        B2 --> U4[verify_fact]
        B3[Delivery Agent] --> U5[send_email]
        B3 --> U6[create_ticket]
    end

tool_choice: controlar cuándo Claude usa herramientas

import anthropic

client = anthropic.Anthropic()

# tool_choice: "any" — Claude SIEMPRE debe llamar una tool
# Útil cuando necesitas output estructurado garantizado
response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    tools=[search_tool, write_tool],
    tool_choice={"type": "any"},  # Garantiza que siempre use una tool
    messages=[{"role": "user", "content": "Analiza el repositorio"}]
)

# tool_choice: "auto" — Claude decide si usar herramientas
# Comportamiento por defecto
response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    tools=[search_tool, write_tool],
    tool_choice={"type": "auto"},
    messages=[{"role": "user", "content": "¿Qué hace esta función?"}]
)

# tool_choice: "tool" — Claude DEBE usar esta herramienta específica
response = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    tools=[format_output_tool],
    tool_choice={"type": "tool", "name": "format_output"},
    messages=[{"role": "user", "content": "Dame el reporte final"}]
)

Cuándo dar acceso cross-role

En algunos casos, un agente necesita una herramienta de otro dominio:

// Synthesis Agent normalmente NO tiene web_search
// PERO puede tener verify_fact (versión limitada de search)
const synthesisAgentTools = [
  write_document_tool,
  format_output_tool,
  // Acceso de solo lectura para verificar datos, no para investigar
  {
    name: "verify_fact",
    description: `Verifica si un dato específico es correcto consultando fuentes confiables.
    Solo úsala para confirmar hechos que ya tienes, no para investigar nuevos temas.
    Input: el dato a verificar. Output: {confirmed: boolean, source: string}.
    NO uses esto para buscar información nueva — usa el Research Agent para eso.`
  }
]

10. Testing de Tool Selection

Cómo verificar que tus descripciones funcionan correctamente.

Test con requests ambiguos

import anthropic
import json

client = anthropic.Anthropic()

# Herramientas con descripciones a testear
tools = [search_product_tool, search_knowledge_base_tool, search_web_tool]

# Test cases: queries ambiguos que deben rutear a la tool correcta
test_cases = [
    {
        "query": "¿tienen el modelo MacBook Pro M3?",
        "expected_tool": "search_product_catalog"
    },
    {
        "query": "¿cómo configuro el adaptador?",
        "expected_tool": "search_knowledge_base"
    },
    {
        "query": "últimas noticias sobre Apple",
        "expected_tool": "search_web"
    }
]

def test_tool_selection():
    failures = []

    for case in test_cases:
        response = client.messages.create(
            model="claude-opus-4-8",
            max_tokens=256,
            tools=tools,
            tool_choice={"type": "any"},
            messages=[{"role": "user", "content": case["query"]}]
        )

        selected_tool = response.content[0].name
        if selected_tool != case["expected_tool"]:
            failures.append({
                "query": case["query"],
                "expected": case["expected_tool"],
                "got": selected_tool
            })

    if failures:
        print("❌ Tool selection failures:")
        print(json.dumps(failures, indent=2))
        return False

    print("✅ Todas las tool selections correctas")
    return True

test_tool_selection()

Iterar sobre descripciones

Si una herramienta se selecciona incorrectamente, el proceso de mejora es:

flowchart LR
    A[Ejecutar test suite] --> B{Algún fallo?}
    B -->|No| C[Descripciones correctas ✅]
    B -->|Sí| D[Identificar query problemática]
    D --> E[Analizar por qué Claude eligió mal]
    E --> F[Mejorar descripción de la tool incorrecta]
    F --> G[Agregar boundary explícito en ambas tools]
    G --> A

Keywords que causan confusión

Evita keywords genéricas en descripciones que se solapan con otras herramientas:

// PROBLEMA: "buscar" aparece en ambas tools
const tool1 = { description: "Busca información en el catálogo" }
const tool2 = { description: "Busca información en la base de conocimiento" }

// SOLUCIÓN: diferenciadores explícitos
const tool1Fixed = {
  description: `Consulta el inventario de productos por SKU, nombre o categoría.
  SOLO para preguntas sobre disponibilidad, precio y stock.
  Queries: "¿tienen X?", "precio de Y", "stock de Z"`
}
const tool2Fixed = {
  description: `Consulta artículos de soporte, manuales y guías de configuración.
  SOLO para preguntas sobre cómo usar productos, troubleshooting o configuración.
  Queries: "cómo configurar X", "error al instalar Y", "pasos para Z"`
}

11. MCP Servers en Workflows CI/CD

Configurar MCP servers para CI

Los MCP servers en CI necesitan credenciales via variables de entorno. Usa el archivo .mcp.json del proyecto (no el global del usuario).

// .mcp.json (en raíz del proyecto, commiteado al repo)
{
  "mcpServers": {
    "database": {
      "command": "npx",
      "args": ["-y", "@company/mcp-database"],
      "env": {
        "DB_HOST": "${DB_HOST}",
        "DB_PORT": "${DB_PORT}",
        "DB_NAME": "${DB_NAME}",
        "DB_USER": "${DB_USER}",
        "DB_PASSWORD": "${DB_PASSWORD}"
      }
    },
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": {
        "GITHUB_TOKEN": "${GITHUB_TOKEN}"
      }
    }
  }
}

GitHub Actions con MCP

# .github/workflows/claude-with-mcp.yml
- name: Run Claude with MCP access
  env:
    ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
    DB_HOST: ${{ secrets.DB_HOST }}
    DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
    DB_USER: ${{ vars.DB_USER }}
    DB_NAME: ${{ vars.DB_NAME }}
    DB_PORT: "5432"
    GITHUB_TOKEN: ${{ github.token }}
  run: |
    claude -p \
      --mcp-config .mcp.json \
      "Verifica que todas las migraciones de base de datos sean reversibles y
      no rompan las queries existentes. Devuelve JSON con {safe: boolean, issues: []}."

Scoping: por qué usar .mcp.json del proyecto

flowchart TD
    A["~/.claude/settings.json (global)"] --> B[Disponible para todos los proyectos]
    C[".mcp.json (proyecto)"] --> D[Solo disponible en este pipeline]

    style A fill:#ff6b6b,color:#fff
    style C fill:#51cf66,color:#fff

    B --> E[❌ Expone tools de producción en proyectos de test]
    D --> F[✅ Solo tools relevantes para este CI/CD]

Usar .mcp.json en el proyecto garantiza que el CI solo tiene acceso a los backends necesarios, no a todos los MCP servers configurados globalmente en la máquina del desarrollador.

Autenticación de servidores MCP en CI

No todos los servidores se autentican con variables de entorno simples. Para OAuth 2.0 existe claude mcp login <name> desde la shell (v2.1.186+), lo que lo hace utilizable en un runner sin sesión interactiva:

# Runner sin display: imprime la URL en vez de abrir el navegador
claude mcp login database --no-browser

# Credenciales preconfiguradas (lo habitual en CI)
claude mcp login database \
  --client-id "$MCP_CLIENT_ID" \
  --client-secret "$MCP_CLIENT_SECRET"

El secreto también puede entregarse por la variable MCP_CLIENT_SECRET, y se guarda en el keychain del sistema, no en el archivo de configuración. claude mcp logout <name> borra las credenciales al final del job.

Para autenticación no-OAuth (Kerberos, SSO interno, tokens efímeros que caducan durante el pipeline) se usa headersHelper: un comando que escribe un objeto JSON de headers en stdout y se ejecuta en cada conexión (sin caché, timeout de 10 s).

{
  "mcpServers": {
    "internal-api": {
      "type": "http",
      "url": "https://api.interna.example.com/mcp",
      "headersHelper": "./scripts/mcp-headers.sh"
    }
  }
}
#!/bin/bash
# scripts/mcp-headers.sh — recibe CLAUDE_CODE_MCP_SERVER_NAME y _URL
TOKEN=$(vault read -field=token secret/ci/mcp)
printf '{"Authorization":"Bearer %s"}' "$TOKEN"

Cuántos servidores conectar: el criterio ya no son los tokens

Tool search está activo por defecto: las definiciones de herramientas MCP se difieren y solo se cargan bajo demanda; al inicio entran únicamente los nombres de herramientas y las instrucciones del servidor. El coste de arranque en tokens de tener varios servidores ya no es el criterio de decisión.

Lo que sí queda como riesgo residual es la superficie de ataque y los permisos: cada servidor conectado es un backend más al que el agente puede llegar en un pipeline donde procesa contenido no confiable, y donde (por correr con -p) la verificación de trust de servidores nuevos está desactivada. Conecta solo los backends que ese job necesita y revisa que confías en cada uno: un servidor que trae contenido externo es un vector de prompt injection.

Como límite de contención adicional, MAX_MCP_OUTPUT_TOKENS acota la salida de las herramientas MCP (default 25.000 tokens).


12. Manejo Seguro de Contenido en CI

El problema: caracteres especiales rompen el JSON

Cuando se pasa un diff como string directo en el prompt, los caracteres especiales (", \n, `) corrompen el JSON que Claude intenta parsear:

# ❌ INCORRECTO: $DIFF sin escapar puede contener comillas, barras, etc.
OUTPUT=$(claude -p \
  --output-format json \
  "Revisa este diff: $DIFF")
# Error: unexpected token in JSON at position 47

La solución: jq -Rs .

jq -Rs . lee stdin como raw string y lo convierte en un string JSON correctamente escapado:

# ✅ CORRECTO: escapar el diff antes de interpolarlo
DIFF=$(git diff origin/${{ github.base_ref }}...HEAD)
DIFF_ESCAPED=$(echo "$DIFF" | jq -Rs .)

# Construir el prompt con jq para garantizar JSON válido
PROMPT=$(jq -n --argjson diff "$DIFF_ESCAPED" \
  '"Revisa este diff de código. Responde en JSON con issues y severidad.\n\nDIFF:\n" + $diff')

OUTPUT=$(claude -p --output-format json "$PROMPT")

Workflow corregido (paso Run Claude Review)

      - name: Run Claude Review
        id: review
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: |
          DIFF=$(git diff origin/${{ github.base_ref }}...HEAD \
            -- ${{ steps.changed.outputs.files }})

          # Escapar contenido arbitrario para uso seguro en JSON
          DIFF_ESCAPED=$(echo "$DIFF" | jq -Rs .)

          PROMPT=$(jq -n --argjson diff "$DIFF_ESCAPED" \
            '"Revisa este diff. JSON: {\"summary\":string,\"issues\":[{\"file\":string,\"line\":number,\"severity\":\"critical|warning|info\",\"message\":string}],\"approved\":boolean}\n\nDIFF:\n" + $diff')

          OUTPUT=$(claude -p --output-format json "$PROMPT" 2>&1)
          echo "output=$OUTPUT" >> $GITHUB_OUTPUT

          CRITICAL=$(echo "$OUTPUT" | jq '[.issues[] | select(.severity == "critical")] | length')
          if [ "$CRITICAL" -gt "0" ]; then
            echo "Se encontraron $CRITICAL issues críticos"
            exit 1
          fi

Regla general: cualquier contenido externo (diff, archivos, logs) que se interpole en un prompt debe pasar por jq -Rs . antes de usarse.


13. Testing de Tool Selection Reliability

Una descripción de herramienta puede parecer correcta pero fallar con queries ambiguas del mundo real. La única forma de saberlo es con tests.

Suite de tests en TypeScript

// tests/tool-selection.test.ts
import Anthropic from "@anthropic-ai/sdk";
import { expect, test, describe } from "vitest";

const client = new Anthropic();

const tools: Anthropic.Tool[] = [
  {
    name: "search_customer",
    description: `Busca un cliente por nombre, email o ID de cliente.
Devuelve: {id, name, email, plan, created_at}.
Úsala cuando pregunten por datos de un cliente específico.
NO la uses para buscar órdenes o productos.
Queries: "datos del cliente X", "email de Juan", "cliente ID-123".`,
    input_schema: {
      type: "object",
      properties: {
        query: { type: "string", description: "Nombre, email o ID del cliente" }
      },
      required: ["query"]
    }
  },
  {
    name: "lookup_order",
    description: `Consulta una orden de compra por número de orden o cliente.
Devuelve: {order_id, status, items[], total, created_at}.
Úsala cuando pregunten por el estado o contenido de una compra.
NO la uses para datos de clientes ni información de productos.
Queries: "estado de la orden #456", "compras de María", "orden de ayer".`,
    input_schema: {
      type: "object",
      properties: {
        query: { type: "string", description: "Número de orden o nombre del cliente" }
      },
      required: ["query"]
    }
  },
  {
    name: "get_product",
    description: `Obtiene información de un producto por nombre, SKU o categoría.
Devuelve: {sku, name, price, stock, description}.
Úsala cuando pregunten por disponibilidad, precio o características de productos.
NO la uses para clientes ni órdenes.
Queries: "precio del SKU-789", "¿tienen laptops?", "características del modelo X".`,
    input_schema: {
      type: "object",
      properties: {
        query: { type: "string", description: "Nombre, SKU o categoría del producto" }
      },
      required: ["query"]
    }
  }
];

const testCases = [
  { query: "¿cuál es el email de Ana García?",         expected: "search_customer" },
  { query: "¿llegó mi pedido del martes?",             expected: "lookup_order"    },
  { query: "¿tienen el modelo Pro en stock?",          expected: "get_product"     },
  { query: "cliente con ID C-9921",                    expected: "search_customer" },
  { query: "estado de la orden #ORD-4455",             expected: "lookup_order"    },
];

describe("tool selection reliability", () => {
  test.each(testCases)("'$query' → $expected", async ({ query, expected }) => {
    const response = await client.messages.create({
      model: "claude-opus-4-8",
      max_tokens: 256,
      tools,
      tool_choice: { type: "any" },
      messages: [{ role: "user", content: query }]
    });

    const selected = (response.content[0] as Anthropic.ToolUseBlock).name;
    expect(selected).toBe(expected);
  });
});

Matriz de resultados: esperado vs obtenido

Ejecutar npx vitest run tests/tool-selection.test.ts produce una tabla clara:

 FAIL  tests/tool-selection.test.ts
  tool selection reliability
    ✓ '¿cuál es el email de Ana García?' → search_customer
    ✗ '¿llegó mi pedido del martes?' → lookup_order
        expected: 'lookup_order'
        received: 'search_customer'   ← "mi pedido" se confunde con cliente
    ✓ '¿tienen el modelo Pro en stock?' → get_product
    ✓ 'cliente con ID C-9921' → search_customer
    ✓ 'estado de la orden #ORD-4455' → lookup_order

Ciclo de iteración

flowchart LR
    A[Ejecutar test suite] --> B{Algún fallo?}
    B -->|No| C[Descripciones confiables]
    B -->|Sí| D[Identificar query fallida]
    D --> E[Leer descripción de la tool incorrecta]
    E --> F["Agregar boundary explícito<br/>NO la uses cuando..."]
    F --> G[Agregar query de ejemplo a ambas tools]
    G --> A

Regla práctica: si dos tools comparten un keyword genérico (cliente, buscar, datos), agrega ese keyword como boundary negativo en la que NO debe responder.


14. Análisis de Cobertura Diferencial en CI

En lugar de medir cobertura total (que puede ocultar gaps en código nuevo), se analiza específicamente qué líneas del diff no tienen cobertura.

Flujo completo

flowchart TD
    A[PR con cambios] --> B["Ejecutar tests con coverage<br/>npx vitest --coverage"]
    B --> C[Generar lcov.info]
    C --> D[Extraer diff del PR]
    D --> E["Claude: analizar qué líneas del diff<br/>no aparecen en lcov.info"]
    E --> F{Gaps críticos?}
    F -->|Sí| G["Fail pipeline<br/>con lista de funciones sin test"]
    F -->|No| H[Pass + reporte informativo]

Script de análisis

#!/bin/bash
# scripts/coverage-diff-analysis.sh

# 1. Generar coverage report
npx vitest run --coverage --reporter=lcov
LCOV_FILE="coverage/lcov.info"

# 2. Obtener funciones modificadas en el diff
DIFF=$(git diff origin/$BASE_BRANCH...HEAD)
CHANGED_FUNCTIONS=$(echo "$DIFF" | grep "^+" | grep -E "^\\+.*(function |const .+ = |=> \{)" | \
  sed 's/^+//')

# 3. Enviar a Claude para análisis cruzado
LCOV_CONTENT=$(cat "$LCOV_FILE" | head -500)  # primeras 500 líneas son suficientes

ANALYSIS=$(claude -p --output-format json \
  "Analiza qué funciones del DIFF no están cubiertas en el LCOV report.
   Responde JSON: {\"covered\": [\"fn1\"], \"uncovered\": [\"fn2\"], \"coverage_pct\": 85}

   DIFF (funciones modificadas):
   $(echo "$CHANGED_FUNCTIONS" | jq -Rs .)

   LCOV REPORT (extracto):
   $(echo "$LCOV_CONTENT" | jq -Rs .)")

UNCOVERED=$(echo "$ANALYSIS" | jq '.uncovered | length')
echo "$ANALYSIS" | jq .

if [ "$UNCOVERED" -gt "3" ]; then
  echo "Más de 3 funciones modificadas sin cobertura de test"
  exit 1
fi

Prompt específico para coverage gap analysis

Analiza el siguiente diff de código y el reporte de cobertura lcov.
Tu tarea: identificar qué funciones o bloques NUEVOS o MODIFICADOS en el diff
no tienen líneas correspondientes en el lcov report.

Reglas:
- Solo reporta funciones que aparecen en el diff (no las que ya existían)
- Una función está "cubierta" si su número de línea aparece en el lcov con DA:<line>,<count> donde count > 0
- Ignora comentarios y líneas de declaración de tipos

Responde con JSON:
{
  "covered": ["nombre de función o descripción"],
  "uncovered": ["nombre de función o descripción"],
  "coverage_pct": 0-100,
  "risk_assessment": "low|medium|high"
}

Integración en GitHub Actions

      - name: Coverage diff analysis
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          BASE_BRANCH: ${{ github.base_ref }}
        run: |
          npx vitest run --coverage --reporter=lcov --silent
          bash scripts/coverage-diff-analysis.sh

Resumen: Puntos Clave para el Examen

ConceptoRegla
Modo no-interactivoFlag -p, --output-format json
--bareOmite hooks, skills, plugins, MCP, auto memory y CLAUDE.md; recomendado para CI/SDK y futuro default de -p
Instalación en CIInstalador nativo (install.sh/install.ps1/install.cmd); npm es opción avanzada
Salida estructurada--json-schema deja el resultado validado en structured_output
Coste por corrida--max-budget-usd
Exit codes0 éxito, 1 ejecución, 2 configuración, 143 SIGTERM
Límite de stdin10MB por pipe desde v2.1.128 — truncar diffs grandes
Permisos en headless--allowedTools "Bash(git diff *)" (el espacio importa) y --permission-mode auto
Auto mode con -pLos bloqueos repetidos del clasificador abortan la sesión
Trust con -pLa verificación de trust está desactivada: el diff del PR es contenido no confiable
Session isolationNueva sesión para generate y para review
CLAUDE.md en CIOutput format explícito, criterios categóricos, umbrales (incompatible con --bare)
Tool descriptionsInput, output, cuándo usar, cuándo NO usar
Error responsesisRetryable: false para datos genuinamente ausentes
Distribución de toolsMenos tools por agente = mayor confiabilidad
tool_choice: "any"Garantiza que Claude siempre llame una tool
.mcp.json en proyectoScope correcto para CI/CD
Auth MCP en CIclaude mcp login <name> --no-browser, --client-secret/MCP_CLIENT_SECRET, headersHelper
Muchos servidores MCPCon tool search por defecto el criterio es superficie de ataque, no tokens
Escaping en CIjq -Rs . para contenido externo en prompts
Tool selection testsSuite TypeScript con queries ambiguas + matriz esperado/obtenido
Coverage diferencialAnalizar solo líneas del diff, no cobertura total

Siguiente: Sistema Multi-Agente de Investigación