diff --git a/schemas/profile.schema.json b/schemas/profile.schema.json index 8d04739..aa67992 100644 --- a/schemas/profile.schema.json +++ b/schemas/profile.schema.json @@ -196,6 +196,34 @@ "description": "Which vendor adapter files to generate" } } + }, + "mcp": { + "type": "object", + "description": "Optional MCP server declarations. Written to all present vendor MCP config files on compose/sync.", + "additionalProperties": false, + "properties": { + "strategy": { + "type": "string", + "enum": ["merge", "replace"], + "default": "merge", + "description": "merge: profile servers are added without removing existing ones. replace: all vendor MCP files are fully replaced by the profile declaration." + }, + "servers": { + "type": "object", + "description": "MCP server entries. Keys are server names; values follow the .mcp.json entry shape.", + "additionalProperties": { + "type": "object", + "required": ["type"], + "properties": { + "type": { "type": "string", "enum": ["stdio", "http"] }, + "command": { "type": "string" }, + "args": { "type": "array", "items": { "type": "string" } }, + "url": { "type": "string" }, + "env": { "type": "object", "additionalProperties": { "type": "string" } } + } + } + } + } } } } diff --git a/tooling/lib/compose.sh b/tooling/lib/compose.sh index ef62aca..f26d2f7 100755 --- a/tooling/lib/compose.sh +++ b/tooling/lib/compose.sh @@ -411,6 +411,19 @@ LOCK cp "$PROFILE_FILE" "$profile_dst" fi + # Seed MCP servers from profile (no-op if mcp key absent) + local has_mcp + has_mcp=$(yq '.mcp.servers // "null"' "$PROFILE_FILE") + if [[ "$has_mcp" != "null" && "$has_mcp" != "{}" ]]; then + local mcp_strategy + mcp_strategy=$(yq '.mcp.strategy // "merge"' "$PROFILE_FILE") + bash "$LIBRARY/tooling/lib/mcp.sh" \ + --action seed \ + --target "$TARGET" \ + --profile-file "$PROFILE_FILE" \ + --strategy "$mcp_strategy" + fi + update_gitignore echo "Composed AGENTS.md → $TARGET/AGENTS.md" fi @@ -619,6 +632,19 @@ compose_nested() { cp "$PROFILE_FILE" "$profile_dst" fi + # Seed MCP servers from profile (no-op if mcp key absent) + local has_mcp + has_mcp=$(yq '.mcp.servers // "null"' "$PROFILE_FILE") + if [[ "$has_mcp" != "null" && "$has_mcp" != "{}" ]]; then + local mcp_strategy + mcp_strategy=$(yq '.mcp.strategy // "merge"' "$PROFILE_FILE") + bash "$LIBRARY/tooling/lib/mcp.sh" \ + --action seed \ + --target "$TARGET" \ + --profile-file "$PROFILE_FILE" \ + --strategy "$mcp_strategy" + fi + update_gitignore echo "Composed AGENTS.md → $TARGET/AGENTS.md" } diff --git a/tooling/lib/mcp.sh b/tooling/lib/mcp.sh index 2d2ddba..49727a9 100755 --- a/tooling/lib/mcp.sh +++ b/tooling/lib/mcp.sh @@ -6,12 +6,16 @@ set -euo pipefail ACTION="" TARGET="" NAME="" +PROFILE_FILE="" +STRATEGY="merge" while [[ $# -gt 0 ]]; do case "$1" in --action) ACTION="$2"; shift 2 ;; --target) TARGET="$2"; shift 2 ;; --name) NAME="$2"; shift 2 ;; + --profile-file) PROFILE_FILE="$2"; shift 2 ;; + --strategy) STRATEGY="$2"; shift 2 ;; *) echo "Unknown argument: $1" >&2; exit 1 ;; esac done @@ -30,6 +34,10 @@ write_json() { local file="$1" local content="$2" local tmp + # Ensure parent directory exists + local dir + dir=$(dirname "$file") + mkdir -p "$dir" tmp=$(mktemp) printf '%s\n' "$content" > "$tmp" mv "$tmp" "$file" @@ -285,10 +293,209 @@ action_remove() { echo "Done." } +# ── Action: seed ───────────────────────────────────────────────────────────────── +action_seed() { + local profile_file="$1" + local strategy="${2:-merge}" + + # Get server names as array first + local server_names=() + while IFS= read -r name; do + [[ -n "$name" && "$name" != "null" ]] && server_names+=("$name") + done < <(yq '.mcp.servers | keys | .[]' "$profile_file" 2>/dev/null || true) + + if [[ ${#server_names[@]} -eq 0 ]]; then + echo "No MCP servers declared in profile — skipping seed" + return 0 + fi + + # ── Seed .mcp.json (Claude) ────────────────────────────────────────── + local mcp_file="$TARGET/.mcp.json" + # Create or update .mcp.json if: file exists OR strategy is replace OR mcp.servers declared in profile + if [[ -f "$mcp_file" || "$strategy" == "replace" || "${#server_names[@]}" -gt 0 ]]; then + local existing_servers updated_servers + existing_servers="{}" + if [[ -f "$mcp_file" ]]; then + existing_servers=$(jq '.mcpServers // {}' "$mcp_file" 2>/dev/null || echo "{}") + fi + + if [[ "$strategy" == "replace" ]]; then + # Build fresh JSON from profile - start with empty + updated_servers="{}" + local name + for name in "${server_names[@]}"; do + local server_yaml server_json + server_yaml=$(yq -r ".mcp.servers.$name" "$profile_file" 2>/dev/null || echo "") + if [[ -n "$server_yaml" && "$server_yaml" != "null" ]]; then + # Convert YAML to JSON using yq -o json + server_json=$(echo "$server_yaml" | yq -o json '.' 2>/dev/null || echo "{}") + updated_servers=$(printf '%s' "$updated_servers" | jq --arg n "$name" --argjson s "$server_json" '. + {($n): $s}') + fi + done + else + # Merge: add only if key doesn't exist + local name + for name in "${server_names[@]}"; do + local exists + exists=$(jq -r --arg n "$name" 'has($n)' "$existing_servers" 2>/dev/null || echo "false") + if [[ "$exists" == "false" ]]; then + local server_yaml server_json + server_yaml=$(yq -r ".mcp.servers.$name" "$profile_file" 2>/dev/null || echo "") + if [[ -n "$server_yaml" && "$server_yaml" != "null" ]]; then + # Convert YAML to JSON using yq -o json + server_json=$(echo "$server_yaml" | yq -o json '.' 2>/dev/null || echo "{}") + existing_servers=$(printf '%s' "$existing_servers" | jq --arg n "$name" --argjson s "$server_json" '. + {($n): $s}') + fi + fi + done + updated_servers="$existing_servers" + fi + + local final_json + final_json=$(printf '{"mcpServers": %s}' "$updated_servers") + write_json "$mcp_file" "$final_json" + echo " ✔ Seeded ${#server_names[@]} server(s) to .mcp.json (strategy: $strategy)" + fi + + # ── Helper: translate a profile server entry to Opencode format ───────── + # Opencode differences from Claude/profile format: + # - type: stdio→local, http→remote + # - command+args → combined array under "command" + # - env → environment + translate_to_opencode() { + local server_json="$1" + jq ' + if .type == "stdio" then .type = "local" + elif .type == "http" then .type = "remote" + else . end + | + if .command != null then + . + { "command": ([ .command ] + (if .args != null then .args else [] end)) } + | del(.args) + else . end + | + if .env != null then + . + { "environment": .env } | del(.env) + else . end + ' <<< "$server_json" + } + + # ── Helper: translate a profile server entry to Gemini format ──────────── + # Gemini differences from Claude/profile format: + # - No "type" field (transport inferred from field presence) + # - url → httpUrl for http entries + translate_to_gemini() { + local server_json="$1" + jq ' + if .type == "http" and .url != null then + . + { "httpUrl": .url } | del(.url) + else . end + | + del(.type) + ' <<< "$server_json" + } + + # ── Seed opencode.json (Opencode) ──────────────────────────────────────── + local opencode_file="$TARGET/opencode.json" + if [[ -f "$opencode_file" ]]; then + local existing_oc updated_oc + existing_oc=$(jq '.mcp // {}' "$opencode_file" 2>/dev/null || echo "{}") + + if [[ "$strategy" == "replace" ]]; then + updated_oc="{}" + local name + for name in "${server_names[@]}"; do + local server_json oc_entry + server_json=$(yq -o json ".mcp.servers.$name" "$profile_file" 2>/dev/null || echo "{}") + if [[ -n "$server_json" && "$server_json" != "null" ]]; then + oc_entry=$(translate_to_opencode "$server_json") + updated_oc=$(printf '%s' "$updated_oc" | jq --arg n "$name" --argjson s "$oc_entry" '. + {($n): $s}') + fi + done + else + # Merge: add only if key doesn't already exist + local name + for name in "${server_names[@]}"; do + local exists + exists=$(printf '%s' "$existing_oc" | jq -r --arg n "$name" 'has($n)') + if [[ "$exists" == "false" ]]; then + local server_json oc_entry + server_json=$(yq -o json ".mcp.servers.$name" "$profile_file" 2>/dev/null || echo "{}") + if [[ -n "$server_json" && "$server_json" != "null" ]]; then + oc_entry=$(translate_to_opencode "$server_json") + existing_oc=$(printf '%s' "$existing_oc" | jq --arg n "$name" --argjson s "$oc_entry" '. + {($n): $s}') + fi + fi + done + updated_oc="$existing_oc" + fi + + local opencode_base final_opencode + opencode_base=$(cat "$opencode_file") + final_opencode=$(printf '%s' "$opencode_base" | jq --argjson mcp "$updated_oc" '. + {mcp: $mcp}') + write_json "$opencode_file" "$final_opencode" + echo " ✔ Seeded ${#server_names[@]} server(s) to opencode.json (strategy: $strategy)" + fi + + # ── Seed .gemini/settings.json (Gemini) ────────────────────────────────── + local gemini_file="$TARGET/.gemini/settings.json" + if [[ -f "$gemini_file" ]]; then + local existing_gs updated_gs + existing_gs=$(jq '.mcpServers // {}' "$gemini_file" 2>/dev/null || echo "{}") + + if [[ "$strategy" == "replace" ]]; then + updated_gs="{}" + local name + for name in "${server_names[@]}"; do + local server_json gs_entry + server_json=$(yq -o json ".mcp.servers.$name" "$profile_file" 2>/dev/null || echo "{}") + if [[ -n "$server_json" && "$server_json" != "null" ]]; then + gs_entry=$(translate_to_gemini "$server_json") + updated_gs=$(printf '%s' "$updated_gs" | jq --arg n "$name" --argjson s "$gs_entry" '. + {($n): $s}') + fi + done + else + # Merge: add only if key doesn't already exist + local name + for name in "${server_names[@]}"; do + local exists + exists=$(printf '%s' "$existing_gs" | jq -r --arg n "$name" 'has($n)') + if [[ "$exists" == "false" ]]; then + local server_json gs_entry + server_json=$(yq -o json ".mcp.servers.$name" "$profile_file" 2>/dev/null || echo "{}") + if [[ -n "$server_json" && "$server_json" != "null" ]]; then + gs_entry=$(translate_to_gemini "$server_json") + existing_gs=$(printf '%s' "$existing_gs" | jq --arg n "$name" --argjson s "$gs_entry" '. + {($n): $s}') + fi + fi + done + updated_gs="$existing_gs" + fi + + local gemini_base final_gemini + gemini_base=$(cat "$gemini_file") + final_gemini=$(printf '%s' "$gemini_base" | jq --argjson mcpServers "$updated_gs" '. + {mcpServers: $mcpServers}') + write_json "$gemini_file" "$final_gemini" + echo " ✔ Seeded ${#server_names[@]} server(s) to .gemini/settings.json (strategy: $strategy)" + fi +} + # ── Dispatch ────────────────────────────────────────────────────────────────── case "$ACTION" in add) action_add ;; remove) action_remove ;; list) action_list ;; - *) echo "Error: unknown action '$ACTION' (expected add|remove|list)" >&2; exit 1 ;; + seed) + # Non-interactive seed from profile - uses globally parsed PROFILE_FILE and STRATEGY + # Set defaults if not provided + [[ -z "$PROFILE_FILE" ]] && { echo "Error: --profile-file required for seed action" >&2; exit 1; } + [[ -f "$PROFILE_FILE" ]] || { echo "Error: profile file not found: $PROFILE_FILE" >&2; exit 1; } + [[ -z "$TARGET" ]] && { echo "Error: --target required for seed action" >&2; exit 1; } + + # Use default strategy if not provided + [[ -z "$STRATEGY" ]] && STRATEGY="merge" + + action_seed "$PROFILE_FILE" "$STRATEGY" + ;; + *) echo "Error: unknown action '$ACTION' (expected add|remove|list|seed)" >&2; exit 1 ;; esac diff --git a/tooling/lib/test.sh b/tooling/lib/test.sh index d157f92..1dce477 100755 --- a/tooling/lib/test.sh +++ b/tooling/lib/test.sh @@ -1095,6 +1095,249 @@ assert_stdout_contains "$T57_OUTPUT" "--dir" "T57" assert_stdout_contains "$T57_OUTPUT" "--global" "T57" assert_stdout_contains "$T57_OUTPUT" "--branch" "T57" +# ══════════════════════════════════════════════════════════════════════════════ +# MCP SERVER SEEDING TESTS (profile mcp: key) +# ══════════════════════════════════════════════════════════════════════════════ + +MCP="$LIBRARY/tooling/lib/mcp.sh" + +# T67 — compose: MCP seed creates .mcp.json with correct keys +run_test "T67 — compose: MCP seed creates .mcp.json" +# Create a profile with mcp.servers +mkdir -p "$TMP/t67" +cat > "$TMP/t67-profile.yaml" <<'EOF' +meta: + name: Test MCP Profile + description: Test profile with MCP servers + version: "1.0.0" +fragments: + base: + - git-conventions +output: + build_command: "" + test_command: "" + lint_command: "" +vendors: + enabled: [] +mcp: + strategy: merge + servers: + github: + type: stdio + command: npx + args: ["-y", "@modelcontextprotocol/server-github"] + postgres: + type: stdio + command: npx + args: ["-y", "@modelcontextprotocol/server-postgres"] + env: + DATABASE_URL: "postgresql://localhost:5432" +EOF + +bash "$COMPOSE" \ + --library "$LIBRARY" \ + --profile-file "$TMP/t67-profile.yaml" \ + --target "$TMP/t67" \ + > /dev/null 2>&1 + +assert_file_exists "$TMP/t67/.mcp.json" "T67" +assert_json_valid "$TMP/t67/.mcp.json" "T67" +assert_file_contains "$TMP/t67/.mcp.json" "github" "T67" +assert_file_contains "$TMP/t67/.mcp.json" "postgres" "T67" +assert_file_contains "$TMP/t67/.mcp.json" '"command": "npx"' "T67" + +# T68 — compose: MCP seed writes to opencode.json with translations +run_test "T68 — compose: MCP seed translates for opencode.json" +mkdir -p "$TMP/t68" +# Create opencode.json first (simulating existing vendor) +echo '{"mcp":{}}' > "$TMP/t68/opencode.json" + +cat > "$TMP/t68-profile.yaml" <<'EOF' +meta: + name: Test MCP Opencode + description: Test profile with MCP servers for opencode + version: "1.0.0" +fragments: + base: + - git-conventions +output: + build_command: "" + test_command: "" + lint_command: "" +vendors: + enabled: [] +mcp: + servers: + test-server: + type: stdio + command: npx + args: ["-y", "some-server"] + env: + MY_TOKEN: "${MY_TOKEN}" +EOF + +bash "$COMPOSE" \ + --library "$LIBRARY" \ + --profile-file "$TMP/t68-profile.yaml" \ + --target "$TMP/t68" \ + > /dev/null 2>&1 + +assert_file_exists "$TMP/t68/opencode.json" "T68" +assert_file_contains "$TMP/t68/opencode.json" '"local"' "T68" +assert_file_contains "$TMP/t68/opencode.json" '"environment"' "T68" + +# T69 — compose: MCP seed writes to .gemini/settings.json without type field +run_test "T69 — compose: MCP seed translates for .gemini/settings.json" +mkdir -p "$TMP/t69/.gemini" +echo '{"mcpServers":{}}' > "$TMP/t69/.gemini/settings.json" + +cat > "$TMP/t69-profile.yaml" <<'EOF' +meta: + name: Test MCP Gemini + description: Test profile with MCP servers for gemini + version: "1.0.0" +fragments: + base: + - git-conventions +output: + build_command: "" + test_command: "" + lint_command: "" +vendors: + enabled: [] +mcp: + servers: + http-server: + type: http + url: "http://localhost:3000" +EOF + +bash "$COMPOSE" \ + --library "$LIBRARY" \ + --profile-file "$TMP/t69-profile.yaml" \ + --target "$TMP/t69" \ + > /dev/null 2>&1 + +assert_file_exists "$TMP/t69/.gemini/settings.json" "T69" +assert_file_contains "$TMP/t69/.gemini/settings.json" "http-server" "T69" +assert_file_contains "$TMP/t69/.gemini/settings.json" "httpUrl" "T69" +# Should NOT contain 'type' field +assert_file_not_contains "$TMP/t69/.gemini/settings.json" '"type"' "T69" + +# T70 — compose: MCP merge strategy preserves existing servers +run_test "T70 — compose: MCP merge preserves existing servers" +mkdir -p "$TMP/t70" +# Pre-existing .mcp.json with a server +echo '{"mcpServers":{"existing-server":{"type":"stdio","command":"echo","args":["hello"]}}}' > "$TMP/t70/.mcp.json" + +cat > "$TMP/t70-profile.yaml" <<'EOF' +meta: + name: Test MCP Merge + description: Test profile with MCP servers merge + version: "1.0.0" +fragments: + base: + - git-conventions +output: + build_command: "" + test_command: "" + lint_command: "" +vendors: + enabled: [] +mcp: + strategy: merge + servers: + new-server: + type: stdio + command: echo + args: ["new"] +EOF + +bash "$COMPOSE" \ + --library "$LIBRARY" \ + --profile-file "$TMP/t70-profile.yaml" \ + --target "$TMP/t70" \ + > /dev/null 2>&1 + +assert_file_contains "$TMP/t70/.mcp.json" "existing-server" "T70" +assert_file_contains "$TMP/t70/.mcp.json" "new-server" "T70" + +# T71 — compose: MCP replace strategy removes old servers +run_test "T71 — compose: MCP replace removes old servers" +mkdir -p "$TMP/t71" +echo '{"mcpServers":{"old-server":{"type":"stdio","command":"old","args":["old"]}}}' > "$TMP/t71/.mcp.json" + +cat > "$TMP/t71-profile.yaml" <<'EOF' +meta: + name: Test MCP Replace + description: Test profile with MCP servers replace + version: "1.0.0" +fragments: + base: + - git-conventions +output: + build_command: "" + test_command: "" + lint_command: "" +vendors: + enabled: [] +mcp: + strategy: replace + servers: + brand-new: + type: http + url: "http://localhost:8080" +EOF + +bash "$COMPOSE" \ + --library "$LIBRARY" \ + --profile-file "$TMP/t71-profile.yaml" \ + --target "$TMP/t71" \ + > /dev/null 2>&1 + +assert_file_contains "$TMP/t71/.mcp.json" "brand-new" "T71" +assert_file_not_contains "$TMP/t71/.mcp.json" "old-server" "T71" + +# T72 — compose: profile without mcp key produces no MCP files +run_test "T72 — compose: profile without mcp key is no-op" +mkdir -p "$TMP/t72" + +cat > "$TMP/t72-profile.yaml" <<'EOF' +meta: + name: Test No MCP + description: Test profile without MCP servers + version: "1.0.0" +fragments: + base: + - git-conventions +output: + build_command: "" + test_command: "" + lint_command: "" +vendors: + enabled: [] +EOF + +bash "$COMPOSE" \ + --library "$LIBRARY" \ + --profile-file "$TMP/t72-profile.yaml" \ + --target "$TMP/t72" \ + > /dev/null 2>&1 + +# Should not touch vendor MCP files (none should exist) +assert_file_not_exists "$TMP/t72/.mcp.json" "T72" +assert_file_not_exists "$TMP/t72/opencode.json" "T72" +assert_file_not_exists "$TMP/t72/.gemini/settings.json" "T72" + +# T73 — lint: schema validation passes with mcp key +run_test "T73 — lint: schema validation passes with mcp key" +T73_EXIT=0 +bash "$LINT" \ + --library "$LIBRARY" \ + > /dev/null 2>&1 || T73_EXIT=$? + +assert_exit_code 0 "$T73_EXIT" "T73" + # ══════════════════════════════════════════════════════════════════════════════ # INDEX STABILITY TESTS # ══════════════════════════════════════════════════════════════════════════════