diff --git a/skills/SKILL.md b/skills/SKILL.md new file mode 100644 index 0000000..0581ed6 --- /dev/null +++ b/skills/SKILL.md @@ -0,0 +1,86 @@ +--- +name: cms-media-options +description: REQUIRED entry point for ALL media and image searches. Every request to find, search, locate, retrieve, browse, get, look up, or use images, photos, pictures, media, icons, illustrations, hero images, banners, thumbnails, logos MUST go through this skill first. Checks which search sources are available, presents only those options, and delegates to the appropriate search skill based on the user's choice. Never search for media directly — always start here. +license: Apache-2.0 +compatibility: Python 3.x or Python 2.7+ recommended, bash 3.2+ (Git Bash/WSL on Windows). Cross-platform: Mac, Windows (Git Bash/WSL), Linux, POSIX. +metadata: + author: afv-library + version: "1.0" +--- + +# CMS Media Search — Source Selection + +**This is the mandatory entry point for ALL media searches.** When the user asks to **find**, **search for**, **locate**, **retrieve**, **browse**, **get**, **look up**, or **use** any kind of visual content — **always start here**. Do not assume a source or call a search tool directly. Check what is available, present only those options, and delegate to the matching search skill. + +## When to Use This Skill + +Activate whenever the user's request involves any visual content, including but not limited to: + +- Images, photos, pictures, media, visuals, graphics +- Icons, illustrations, banners, thumbnails, logos +- Hero images, background images, feature images, cover images +- Any asset described as visual (e.g. "something for the carousel", "a picture for the header") + +Example triggers: + +- "Find a modern luxury apartment exterior and use it in the hero section" +- "I need a hero image for the landing page" +- "Search for family lifestyle photos for the carousel" +- "Get me a logo for the about page" +- "Look up some banner graphics" +- "Can you find product images?" + +--- + +## Step 1: Check Source Availability + +**Before presenting any options**, run the MCP availability check script: + +```bash +python3 scripts/check_mcp.py +``` + +Or if Python is not available: + +```bash +bash scripts/check_mcp.sh +``` + +The script returns JSON: +```json +{"cms_search": true, "data_cloud": false, "unsplash": true} +``` + +**Only present sources that are `true` in the output**, plus **Other** (always available). + +### Example: All sources available +> I can help you find that image. Where would you like to search? +> 1. **Data Cloud – AI Hybrid Search (Salesforce CMS + 3rd-party DAMs)** +> 2. **CMS Keyword Search (Salesforce CMS)** +> 3. **Unsplash** +> 4. **Other** (please specify) + +### Example: Only CMS available +> I can help you find that image. Where would you like to search? +> 1. **CMS Keyword Search (Salesforce CMS)** +> 2. **Other** (please specify) + +## Step 2: Delegate to Search Skill + +Only after the user selects an option, follow the matching skill by **source name** (not number — numbers change based on availability): + +| User selects | Action | +|---|---| +| **CMS Keyword Search (Salesforce CMS)** | Read and follow `../cms-keyword-search/SKILL.md` | +| **Data Cloud – AI Hybrid Search (Salesforce CMS + 3rd-party DAMs)** | Read and follow `../cms-d360-search/SKILL.md` | +| **Unsplash** | Invoke the Unsplash MCP tools +| **Other** | Ask the user for the source URL or asset library details, then retrieve accordingly | + +## Step 3: Present Results + +After the delegated skill returns results: + +- Display returned assets with preview thumbnails when available. +- Include asset title, source system, and relevance score or tags. +- Let the user confirm which asset to use before inserting it into the page or component. +- Do **not** automatically use the first result — user selection is required. diff --git a/skills/build_cms_search_input.py b/skills/build_cms_search_input.py new file mode 100644 index 0000000..05d750c --- /dev/null +++ b/skills/build_cms_search_input.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +Build CMS Search Input JSON + +Generates properly formatted input JSON for search_media_cms_channels MCP tool. + +Usage: + python3 build_cms_search_input.py --keywords "car,automobile,vehicle" --taxonomies "Modern,Luxury" --locale "en_US" + + # With empty keywords + python3 build_cms_search_input.py --keywords "" --taxonomies "Bright,Spacious" --locale "en_US" + + # With empty taxonomies + python3 build_cms_search_input.py --keywords "logo,brand" --taxonomies "" --locale "en_US" + + # With page parameters + python3 build_cms_search_input.py --keywords "car" --taxonomies "Luxury" --locale "en_US" --limit 10 --offset 0 +""" + +import argparse +import json +import sys + + +def build_search_keyword(keywords): + """ + Convert comma-separated keywords to OR-separated format. + + Args: + keywords: Comma-separated string of keywords, or empty string + + Returns: + OR-separated keyword string, or empty string + + Examples: + "car,automobile,vehicle" -> "car OR automobile OR vehicle" + "" -> "" + "logo" -> "logo" + """ + if not keywords or keywords.strip() == "": + return "" + + # Split by comma, strip whitespace, filter empty + keyword_list = [k.strip() for k in keywords.split(',') if k.strip()] + + if not keyword_list: + return "" + + # Join with OR + return " OR ".join(keyword_list) + + +def build_taxonomy_expression(taxonomies): + """ + Convert comma-separated taxonomies to JSON string format. + + Args: + taxonomies: Comma-separated string of taxonomy labels, or empty string + + Returns: + JSON string in format: {"OR": ["Label1", "Label2", "Label3"]}, or "{}" + + Examples: + "Modern,Luxury,Premium" -> "{\"OR\": [\"Modern\", \"Luxury\", \"Premium\"]}" + "" -> "{}" + "Bright" -> "{\"OR\": [\"Bright\"]}" + """ + if not taxonomies or taxonomies.strip() == "": + return "{}" + + # Split by comma, strip whitespace, filter empty + taxonomy_list = [t.strip() for t in taxonomies.split(',') if t.strip()] + + if not taxonomy_list: + return "{}" + + # Build JSON object and convert to string + taxonomy_obj = {"OR": taxonomy_list} + return json.dumps(taxonomy_obj) + + +def build_cms_search_input(keywords, taxonomies, locale="en_US", page_offset=0, page_limit=5): + """ + Build the complete input JSON for search_media_cms_channels. + + Args: + keywords: Comma-separated string of keywords + taxonomies: Comma-separated string of taxonomy labels + locale: Language locale (e.g., "en_US", "es_MX", "fr_CA") + page_offset: Starting offset for pagination (default: 0) + page_limit: Number of results to return (default: 5) + + Returns: + Dictionary with properly formatted input + """ + return { + "inputs": [{ + "searchKeyword": build_search_keyword(keywords), + "taxonomyExpression": build_taxonomy_expression(taxonomies), + "searchLanguage": locale, + "channelIds": "", + "channelType": "PublicUnauthenticated", + "contentTypeFqns": "sfdc_cms__image", + "pageOffset": page_offset, + "pageLimit": page_limit + }] + } + + +def main(): + parser = argparse.ArgumentParser( + description='Build properly formatted input JSON for CMS image search', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Basic search + python3 build_cms_search_input.py --keywords "car,automobile,vehicle" --taxonomies "Modern,Luxury" --locale "en_US" + + # Search with only taxonomies (no keywords) + python3 build_cms_search_input.py --keywords "" --taxonomies "Bright,Spacious,Airy" --locale "en_US" + + # Search with only keywords (no taxonomies) + python3 build_cms_search_input.py --keywords "logo,brand,corporate" --taxonomies "" --locale "en_US" + + # With custom page limit + python3 build_cms_search_input.py --keywords "apartment" --taxonomies "Luxury" --locale "en_US" --limit 20 + """ + ) + + parser.add_argument( + '--keywords', + required=True, + help='Comma-separated keywords (e.g., "car,automobile,vehicle"). Use empty string "" for no keywords.' + ) + + parser.add_argument( + '--taxonomies', + required=True, + help='Comma-separated taxonomy labels (e.g., "Modern,Luxury,Premium"). Use empty string "" for no taxonomies.' + ) + + parser.add_argument( + '--locale', + default='en_US', + help='Language locale (e.g., "en_US", "es_MX", "fr_CA"). Default: "en_US"' + ) + + parser.add_argument( + '--offset', + type=int, + default=0, + help='Page offset for pagination. Default: 0' + ) + + parser.add_argument( + '--limit', + type=int, + default=5, + help='Number of results to return. Default: 5' + ) + + args = parser.parse_args() + + # Build the input + input_json = build_cms_search_input( + keywords=args.keywords, + taxonomies=args.taxonomies, + locale=args.locale, + page_offset=args.offset, + page_limit=args.limit + ) + + # Output JSON + print(json.dumps(input_json, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/build_cms_search_input.sh b/skills/build_cms_search_input.sh new file mode 100644 index 0000000..2b3fd66 --- /dev/null +++ b/skills/build_cms_search_input.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# Build CMS Search Input JSON +# +# Generates properly formatted input JSON for search_media_cms_channels MCP tool. +# Cross-platform compatible: Mac, Windows (Git Bash/MSYS/Cygwin), Linux, POSIX +# +# Usage: +# bash build_cms_search_input.sh --keywords "car,automobile,vehicle" --taxonomies "Modern,Luxury" --locale "en_US" +# bash build_cms_search_input.sh --keywords "car" --taxonomies "Luxury" +# +# Examples: +# # With empty keywords +# bash build_cms_search_input.sh --keywords "" --taxonomies "Bright,Spacious" --locale "en_US" +# +# # With empty taxonomies +# bash build_cms_search_input.sh --keywords "logo,brand" --taxonomies "" --locale "en_US" +# +# # With page parameters +# bash build_cms_search_input.sh --keywords "car" --taxonomies "Luxury" --locale "en_US" --limit 10 --offset 0 + +set -e + +# Default values +KEYWORDS="" +TAXONOMIES="" +LOCALE="en_US" +PAGE_OFFSET=0 +PAGE_LIMIT=5 + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --keywords) + KEYWORDS="$2" + shift 2 + ;; + --taxonomies) + TAXONOMIES="$2" + shift 2 + ;; + --locale) + LOCALE="$2" + shift 2 + ;; + --offset) + PAGE_OFFSET="$2" + shift 2 + ;; + --limit) + PAGE_LIMIT="$2" + shift 2 + ;; + -h|--help) + echo "Usage: $0 --keywords KEYWORDS --taxonomies TAXONOMIES [--locale LOCALE] [--offset OFFSET] [--limit LIMIT]" + echo "" + echo "Required arguments:" + echo " --keywords KEYWORDS Comma-separated keywords (e.g., 'car,automobile,vehicle')" + echo " Use empty string '' for no keywords" + echo " --taxonomies TAXONOMIES Comma-separated taxonomy labels (e.g., 'Modern,Luxury')" + echo " Use empty string '' for no taxonomies" + echo "" + echo "Optional arguments:" + echo " --locale LOCALE Language locale (default: 'en_US')" + echo " --offset OFFSET Page offset for pagination (default: 0)" + echo " --limit LIMIT Number of results to return (default: 5)" + echo "" + echo "Examples:" + echo " $0 --keywords 'car,automobile,vehicle' --taxonomies 'Modern,Luxury' --locale 'en_US'" + echo " $0 --keywords '' --taxonomies 'Bright,Spacious' --locale 'en_US'" + echo " $0 --keywords 'logo,brand' --taxonomies '' --locale 'en_US' --limit 10" + exit 0 + ;; + *) + echo "Unknown argument: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +# Function to build search keyword (OR-separated) +build_search_keyword() { + local keywords="$1" + + if [[ -z "$keywords" ]]; then + echo "" + return + fi + + # Replace commas with " OR " + local result=$(echo "$keywords" | sed 's/,/ OR /g' | sed 's/ */ /g') + echo "$result" +} + +# Function to build taxonomy expression (JSON string) +build_taxonomy_expression() { + local taxonomies="$1" + + if [[ -z "$taxonomies" ]]; then + echo "{}" + return + fi + + # Split by comma and build JSON array + IFS=',' read -ra taxonomy_array <<< "$taxonomies" + + # Build JSON array string + local json_array="[" + local first=true + for tax in "${taxonomy_array[@]}"; do + tax=$(echo "$tax" | xargs) # Trim whitespace + if [[ -n "$tax" ]]; then + if [[ "$first" == true ]]; then + first=false + else + json_array+=", " + fi + json_array+="\"$tax\"" + fi + done + json_array+="]" + + # Build complete JSON string + echo "{\"OR\": $json_array}" +} + +# Build components +SEARCH_KEYWORD=$(build_search_keyword "$KEYWORDS") +TAXONOMY_EXPRESSION=$(build_taxonomy_expression "$TAXONOMIES") + +# Output JSON +cat <" + if auth_header.startswith('Authorization: Bearer '): + return auth_header.replace('Authorization: Bearer ', '') + + return None + + +def get_base_headers(): + """Get base headers for MCP requests (without session ID).""" + headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream' + } + token = get_bearer_token() + if token: + headers['Authorization'] = f'Bearer {token}' + return headers + + +def initialize_mcp_session(endpoint_url): + """ + Initialize MCP session and get session ID. + Args: + endpoint_url: The MCP server endpoint URL + Returns session ID or None if initialization fails. + """ + headers = get_base_headers() + + # Step 1: Initialize + payload = { + "jsonrpc": "2.0", + "method": "initialize", + "id": "0", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "CheckMCPScript", "version": "1.0.0"} + } + } + + try: + status, resp_headers, data = _http_post_json(endpoint_url, headers, payload, TIMEOUT) + if status == 200 and data is not None: + # Try to get from Mcp-Session-Id header + session_id = resp_headers.get('Mcp-Session-Id') if resp_headers else None + if session_id: + return session_id + + # Try to get from response body + if 'result' in data and isinstance(data['result'], dict): + session_id = data['result'].get('sessionId') or data['result'].get('session_id') + if session_id: + return session_id + except Exception: + pass + + return None + + +def send_initialized_notification(endpoint_url, session_id): + """ + Send initialized notification to MCP server. + Args: + endpoint_url: The MCP server endpoint URL + session_id: The session ID from initialization + Returns True if successful, False otherwise. + """ + headers = get_base_headers() + headers['Mcp-Session-Id'] = session_id + + payload = { + "jsonrpc": "2.0", + "method": "notifications/initialized" + } + + try: + status, _, _ = _http_post_json(endpoint_url, headers, payload, TIMEOUT) + return status in [200, 202] # Accept both 200 and 202 + except Exception: + return False + + +def list_available_tools(): + """ + List all available tools from MCP server using JSON-RPC. + Follows proper MCP initialization flow. + Returns a list of tool names, or empty list if unavailable. + """ + # Get the endpoint URL + endpoint_url = get_contentmcp_url() + if not endpoint_url: + # stdio type or no URL configured + return [] + + # Step 1: Initialize and get session ID + session_id = initialize_mcp_session(endpoint_url) + if not session_id: + return [] + + # Step 2: Send initialized notification + if not send_initialized_notification(endpoint_url, session_id): + return [] + + # Step 3: List tools + headers = get_base_headers() + headers['Mcp-Session-Id'] = session_id + + payload = { + "jsonrpc": "2.0", + "method": "tools/list", + "id": "1", + "params": {} + } + + try: + status, _, data = _http_post_json(endpoint_url, headers, payload, TIMEOUT) + if status == 200 and data and 'result' in data and 'tools' in data['result']: + tools = data['result']['tools'] + tool_names = [tool.get('name') for tool in tools if isinstance(tool, dict) and 'name' in tool] + return tool_names + except Exception: + pass + + return [] + + +def check_mcp_health(): + """Check if MCP server is reachable by attempting to list tools.""" + tools = list_available_tools() + return len(tools) > 0 + + +def check_tool_available(tool_name): + """ + Check if a specific tool is available via MCP server. + Uses JSON-RPC to list tools and searches for the tool name. + """ + available_tools = list_available_tools() + return tool_name in available_tools + + +def main(): + """Main execution.""" + result = { + "cms_search": False, + "data_cloud": False, + "unsplash": False + } + + # First check if contentmcp server is enabled in settings + if not is_contentmcp_enabled(): + # contentmcp is disabled, return all false + print(json.dumps(result)) + return 0 + + # Check if it's stdio type + content_config = get_content_server_config() + + if content_config.get('type') == 'stdio': + # For stdio type, we can't check via URL + # Assume tools are available if the server is enabled + # The IDE/extension manages the connection + result["cms_search"] = True + result["data_cloud"] = True + else: + # For non-stdio (like mcp-remote), check via URL + if check_mcp_health(): + # Check for CMS search tool + if check_tool_available("search_media_cms_channels"): + result["cms_search"] = True + + # Check for Data Cloud search tool + if check_tool_available("search_electronic_media"): + result["data_cloud"] = True + + # Check for Unsplash MCP server separately + if is_unsplash_mcp_enabled(): + result["unsplash"] = True + + # Output JSON result + print(json.dumps(result)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/check_mcp.sh b/skills/check_mcp.sh new file mode 100644 index 0000000..fb7f94d --- /dev/null +++ b/skills/check_mcp.sh @@ -0,0 +1,448 @@ +#!/bin/bash + +# Check MCP Content Server Availability and Tools +# +# Returns JSON with availability status for each media source: +# { +# "cms_search": true|false, +# "data_cloud": true|false, +# "unsplash": true|false +# } + +set -e + +TIMEOUT=5 + +# URL patterns that identify the content MCP endpoint (name-agnostic) +CONTENT_ENDPOINT_PATTERN="platform/content" +CONTENT_READONLY_PATTERN="content-readonly" + +# Args pattern that identifies the Unsplash MCP server (name-agnostic) +UNSPLASH_MCP_ARGS_PATTERN="unsplash-mcp-server" + + +# --------------------------------------------------------------------------- +# Platform detection and settings path +# --------------------------------------------------------------------------- +get_ide_config_root() { + if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" || "$OSTYPE" == "cygwin" ]]; then + local appdata="${APPDATA:-$HOME/AppData/Roaming}" + echo "$appdata/Code" + elif [[ "$OSTYPE" == "darwin"* ]]; then + echo "$HOME/Library/Application Support/Code" + else + local xdg="${XDG_CONFIG_HOME:-$HOME/.config}" + echo "$xdg/Code" + fi +} + +SETTINGS_PATH="$(get_ide_config_root)/User/globalStorage/salesforce.salesforcedx-einstein-gpt/settings/a4d_mcp_settings.json" + +# --------------------------------------------------------------------------- +# JSON helpers — jq preferred, grep/sed fallback +# --------------------------------------------------------------------------- + +_has_jq() { command -v jq &> /dev/null; } + +# Get the full raw settings as a string (only useful with jq) +_settings_exists() { [[ -f "$SETTINGS_PATH" ]]; } + +# --------------------------------------------------------------------------- +# Name-agnostic content server detection +# Scans ALL mcpServers entries for args containing CONTENT_ENDPOINT_PATTERN +# or CONTENT_READONLY_PATTERN. Returns the server key (name) or empty. +# --------------------------------------------------------------------------- +get_content_server_key() { + if ! _settings_exists; then echo ""; return; fi + + if _has_jq; then + local key + key=$(jq -r ' + .mcpServers // {} | to_entries[] | + select(.value.args? // [] | map(select(type == "string")) | + any(contains("'"$CONTENT_ENDPOINT_PATTERN"'") or contains("'"$CONTENT_READONLY_PATTERN"'"))) + | .key' "$SETTINGS_PATH" 2>/dev/null | head -n 1) + echo "$key" + else + # grep fallback: look for any args line containing the patterns + local key + key=$(grep -B 20 "$CONTENT_ENDPOINT_PATTERN\|$CONTENT_READONLY_PATTERN" "$SETTINGS_PATH" 2>/dev/null \ + | grep -o '"[^"]*": *{' | tail -n 1 | sed 's/[": {]//g') + echo "$key" + fi +} + +# --------------------------------------------------------------------------- +# Content server config queries (all name-agnostic via get_content_server_key) +# --------------------------------------------------------------------------- +is_content_server_disabled() { + local key + key=$(get_content_server_key) + [[ -z "$key" ]] && return 0 # no server found → treat as disabled + + if _has_jq; then + local disabled + disabled=$(jq -r ".mcpServers[\"$key\"].disabled // false" "$SETTINGS_PATH" 2>/dev/null) + [[ "$disabled" == "true" ]] && return 0 + return 1 + else + if grep -A 5 "\"$key\"" "$SETTINGS_PATH" 2>/dev/null | grep -q '"disabled": *true'; then + return 0 + fi + return 1 + fi +} + +is_contentmcp_enabled() { + if ! _settings_exists; then return 1; fi + + local key + key=$(get_content_server_key) + [[ -z "$key" ]] && return 1 + + if is_content_server_disabled; then return 1; fi + return 0 +} + +is_stdio_type() { + if ! _settings_exists; then return 1; fi + + local key + key=$(get_content_server_key) + [[ -z "$key" ]] && return 1 + + if _has_jq; then + local stype + stype=$(jq -r ".mcpServers[\"$key\"].type // empty" "$SETTINGS_PATH" 2>/dev/null) + [[ "$stype" == "stdio" ]] && return 0 + else + if grep -A 10 "\"$key\"" "$SETTINGS_PATH" 2>/dev/null | grep -q '"type": *"stdio"'; then + return 0 + fi + fi + return 1 +} + +# --------------------------------------------------------------------------- +# Extract the MCP content endpoint URL from settings args (name-agnostic). +# Returns empty if not found — no localhost or env var fallback. +# --------------------------------------------------------------------------- +get_contentmcp_url() { + local key + key=$(get_content_server_key) + [[ -z "$key" ]] && { echo ""; return; } + + if _has_jq; then + local url + url=$(jq -r " + .mcpServers[\"$key\"].args // [] | map(select(type == \"string\")) | + map(select(contains(\"$CONTENT_ENDPOINT_PATTERN\") or contains(\"$CONTENT_READONLY_PATTERN\"))) + | .[0] // empty" "$SETTINGS_PATH" 2>/dev/null) + if [[ -n "$url" ]]; then echo "$url"; return; fi + else + local url + url=$(grep -o 'http[s]*://[^"]*' "$SETTINGS_PATH" 2>/dev/null \ + | grep -E "$CONTENT_ENDPOINT_PATTERN|$CONTENT_READONLY_PATTERN" | head -n 1) + if [[ -n "$url" ]]; then echo "$url"; return; fi + fi + + echo "" +} + +# --------------------------------------------------------------------------- +# Name-agnostic Unsplash detection. +# Checks (1) root-level "unsplash" key and (2) any mcpServers entry whose +# args contain "unsplash-mcp-server". +# --------------------------------------------------------------------------- +get_unsplash_server_key() { + if ! _settings_exists; then echo ""; return; fi + + if _has_jq; then + # Check root-level "unsplash" key first + local root_unsplash + root_unsplash=$(jq -r '.unsplash // empty' "$SETTINGS_PATH" 2>/dev/null) + if [[ -n "$root_unsplash" && "$root_unsplash" != "null" ]]; then + echo "__root__" + return + fi + + # Scan mcpServers for any server with unsplash-mcp-server in args + local key + key=$(jq -r ' + .mcpServers // {} | to_entries[] | + select(.value.args? // [] | map(select(type == "string")) | + any(contains("'"$UNSPLASH_MCP_ARGS_PATTERN"'"))) + | .key' "$SETTINGS_PATH" 2>/dev/null | head -n 1) + echo "$key" + else + # grep fallback: check root "unsplash" or args containing the pattern + if grep -q "\"unsplash\"" "$SETTINGS_PATH" 2>/dev/null; then + echo "unsplash" + return + fi + if grep -q "$UNSPLASH_MCP_ARGS_PATTERN" "$SETTINGS_PATH" 2>/dev/null; then + local key + key=$(grep -B 20 "$UNSPLASH_MCP_ARGS_PATTERN" "$SETTINGS_PATH" 2>/dev/null \ + | grep -o '"[^"]*": *{' | tail -n 1 | sed 's/[": {]//g') + echo "$key" + fi + fi +} + +is_unsplash_mcp_enabled() { + if ! _settings_exists; then return 1; fi + + local key + key=$(get_unsplash_server_key) + [[ -z "$key" ]] && return 1 + + if [[ "$key" == "__root__" ]]; then + # Root-level unsplash: check disabled + if _has_jq; then + local disabled + disabled=$(jq -r '.unsplash.disabled // false' "$SETTINGS_PATH" 2>/dev/null) + [[ "$disabled" == "true" ]] && return 1 + fi + return 0 + fi + + # mcpServers entry + if _has_jq; then + local disabled + disabled=$(jq -r ".mcpServers[\"$key\"].disabled // false" "$SETTINGS_PATH" 2>/dev/null) + [[ "$disabled" == "true" ]] && return 1 + return 0 + else + if grep -A 5 "\"$key\"" "$SETTINGS_PATH" 2>/dev/null | grep -q '"disabled": *true'; then + return 1 + fi + return 0 + fi +} + +# --------------------------------------------------------------------------- +# Bearer token extraction (name-agnostic: from the matched content server) +# Looks for --header arg followed by "Authorization: Bearer ". +# --------------------------------------------------------------------------- +get_bearer_token() { + if ! _settings_exists; then echo ""; return; fi + + local key + key=$(get_content_server_key) + [[ -z "$key" ]] && { echo ""; return; } + + if _has_jq; then + # Find the arg after "--header" that starts with "Authorization: Bearer " + local token + token=$(jq -r " + .mcpServers[\"$key\"].args // [] | to_entries | + map(select(.value == \"--header\")) | + .[0].key as \$idx | + if \$idx then + .mcpServers[\"$key\"].args[\$idx + 1] // empty + else empty end + " "$SETTINGS_PATH" 2>/dev/null) + + # jq path above is tricky; simpler approach: iterate pairs + token=$(jq -r " + [.mcpServers[\"$key\"].args // []] | .[0] | . as \$args | + [range(0; length - 1)] | + map(select(\$args[.] == \"--header\" and (\$args[. + 1] | startswith(\"Authorization: Bearer \")))) | + .[0] as \$i | + if \$i then \$args[\$i + 1] | sub(\"Authorization: Bearer \"; \"\") else empty end + " "$SETTINGS_PATH" 2>/dev/null) + echo "$token" + else + local token + token=$(grep -o 'Authorization: Bearer [^"]*' "$SETTINGS_PATH" 2>/dev/null \ + | sed 's/Authorization: Bearer //' | head -n 1) + echo "$token" + fi +} + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- +MCP_ENDPOINT="" +BEARER_TOKEN="" + +_curl_post() { + local url="$1" + local payload="$2" + local include_headers="${3:-false}" + + local -a curl_args=(-s -m "$TIMEOUT") + + if [[ "$include_headers" == "true" ]]; then + curl_args+=(-i) + fi + + if [[ -n "$BEARER_TOKEN" ]]; then + curl_args+=(-H "Authorization: Bearer $BEARER_TOKEN") + fi + curl_args+=(-H "Content-Type: application/json") + curl_args+=(-H "Accept: application/json, text/event-stream") + + if [[ -n "${SESSION_ID:-}" ]]; then + curl_args+=(-H "Mcp-Session-Id: $SESSION_ID") + fi + + curl_args+=(-X POST -d "$payload" "$url") + curl "${curl_args[@]}" 2>/dev/null +} + +_curl_post_status() { + local url="$1" + local payload="$2" + + local -a curl_args=(-s -o /dev/null -w "%{http_code}" -m "$TIMEOUT") + + if [[ -n "$BEARER_TOKEN" ]]; then + curl_args+=(-H "Authorization: Bearer $BEARER_TOKEN") + fi + curl_args+=(-H "Content-Type: application/json") + curl_args+=(-H "Accept: application/json, text/event-stream") + + if [[ -n "${SESSION_ID:-}" ]]; then + curl_args+=(-H "Mcp-Session-Id: $SESSION_ID") + fi + + curl_args+=(-X POST -d "$payload" "$url") + curl "${curl_args[@]}" 2>/dev/null +} + +# --------------------------------------------------------------------------- +# MCP JSON-RPC session flow +# --------------------------------------------------------------------------- +SESSION_ID="" + +initialize_mcp_session() { + if ! command -v curl &> /dev/null; then echo ""; return 1; fi + + local payload='{"jsonrpc":"2.0","method":"initialize","id":"0","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"CheckMCPScript","version":"1.0.0"}}}' + + local response + response=$(_curl_post "$MCP_ENDPOINT" "$payload" "true") + + # Try Mcp-Session-Id from response headers + local sid + sid=$(echo "$response" | grep -i "^Mcp-Session-Id:" | sed 's/^[^:]*: *//' | tr -d '\r\n ') + if [[ -n "$sid" ]]; then echo "$sid"; return 0; fi + + # Try from response body: result.sessionId or result.session_id + local body + body=$(echo "$response" | sed -n '/^\r*$/,$p' | tail -n +2) + if [[ -n "$body" ]] && _has_jq; then + sid=$(echo "$body" | jq -r '.result.sessionId // .result.session_id // empty' 2>/dev/null) + if [[ -n "$sid" ]]; then echo "$sid"; return 0; fi + elif [[ -n "$body" ]]; then + sid=$(echo "$body" | grep -o '"sessionId":"[^"]*"' | sed 's/"sessionId":"//;s/"//' | head -n 1) + if [[ -z "$sid" ]]; then + sid=$(echo "$body" | grep -o '"session_id":"[^"]*"' | sed 's/"session_id":"//;s/"//' | head -n 1) + fi + if [[ -n "$sid" ]]; then echo "$sid"; return 0; fi + fi + + echo "" + return 1 +} + +send_initialized_notification() { + local sid="$1" + if ! command -v curl &> /dev/null; then return 1; fi + + SESSION_ID="$sid" + local payload='{"jsonrpc":"2.0","method":"notifications/initialized"}' + + local status_code + status_code=$(_curl_post_status "$MCP_ENDPOINT" "$payload") + + # Accept both 200 and 202 (matching Python) + if [[ "$status_code" == "200" || "$status_code" == "202" ]]; then + return 0 + fi + return 1 +} + +list_available_tools() { + if ! command -v curl &> /dev/null; then echo ""; return 1; fi + + local sid + sid=$(initialize_mcp_session) + if [[ -z "$sid" ]]; then echo ""; return 1; fi + + if ! send_initialized_notification "$sid"; then echo ""; return 1; fi + + SESSION_ID="$sid" + local payload='{"jsonrpc":"2.0","method":"tools/list","id":"1","params":{}}' + + local response + response=$(_curl_post "$MCP_ENDPOINT" "$payload") + + if [[ -n "$response" ]]; then + echo "$response" + return 0 + fi + echo "" + return 1 +} + +check_mcp_health() { + local response + response=$(list_available_tools) + if [[ -n "$response" ]] && echo "$response" | grep -q '"tools"'; then + return 0 + fi + return 1 +} + +check_tool_available() { + local tool_name="$1" + local tools_response + tools_response=$(list_available_tools) + + if [[ -n "$tools_response" ]]; then + if echo "$tools_response" | grep -q "\"name\": *\"$tool_name\""; then + return 0 + fi + if echo "$tools_response" | grep -q "\"name\":\"$tool_name\""; then + return 0 + fi + fi + return 1 +} + +# =========================================================================== +# Main +# =========================================================================== +cms_search=false +data_cloud=false +unsplash=false + +if ! is_contentmcp_enabled; then + echo '{"cms_search": false, "data_cloud": false, "unsplash": false}' + exit 0 +fi + +if is_stdio_type; then + cms_search=true + data_cloud=true +else + MCP_ENDPOINT=$(get_contentmcp_url) + BEARER_TOKEN=$(get_bearer_token) + + if check_mcp_health; then + if check_tool_available "search_media_cms_channels"; then + cms_search=true + fi + if check_tool_available "search_electronic_media"; then + data_cloud=true + fi + fi +fi + +if is_unsplash_mcp_enabled; then + unsplash=true +fi + +echo "{\"cms_search\": $cms_search, \"data_cloud\": $data_cloud, \"unsplash\": $unsplash}"