From ebe1c78ef1d7fc3b1361a519d49d0b82b17ff397 Mon Sep 17 00:00:00 2001 From: Spaike Date: Fri, 8 May 2026 17:24:29 +0200 Subject: [PATCH] =?UTF-8?q?Cauldron=20Cloud=20MCP=20Server=20=E2=80=94=20i?= =?UTF-8?q?nitial=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connects Claude Desktop to the Cauldron M&A portal via MCP. Requires Python 3.10+ and an API key from the portal. --- README.md | 109 +++++++++ requirements.txt | 2 + server.py | 574 +++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 210 +++++++++++++++++ 4 files changed, 895 insertions(+) create mode 100644 README.md create mode 100644 requirements.txt create mode 100644 server.py create mode 100644 setup.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e0b8f5 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# Cauldron Cloud — MCP Server + +Connects Claude Desktop (or any MCP client) to the Cauldron M&A portal. + +## Quick setup — 3 commands + +```bash +git clone https://git.zerotohero.it/Spaike/cauldron-mcp.git +cd cauldron-mcp +python setup.py +``` + +The wizard will: +1. Install dependencies automatically +2. Open the browser to the API Keys page on the portal +3. Read the key you paste into the terminal +4. Write `claude_desktop_config.json` without touching your other settings + +Then restart Claude Desktop — done. + +--- + +## Manual setup + +### Requirements + +- Python 3.10+ +- pip + +### Install dependencies + +```bash +pip install mcp httpx +``` + +### Get your API key + +Go to the Cauldron portal → user menu → **API Keys** → click **Generate**. +Your key looks like: `cldrn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` + +> The key is shown **once only**. Store it somewhere safe. + +### Configure Claude Desktop + +Open the config file: + +- **Mac**: `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Linux**: `~/.config/Claude/claude_desktop_config.json` +- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +Add the `mcpServers` block: + +```json +{ + "mcpServers": { + "cauldron": { + "command": "python", + "args": ["/absolute/path/to/cauldron-mcp/server.py"], + "env": { + "CAULDRON_API_KEY": "cldrn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + } +} +``` + +Restart Claude Desktop. + +--- + +## Available tools + +| Tool | Description | +|---|---| +| `get_my_profile` | Your user profile and firm | +| `list_deals` | List deals with filters (type, stage, industry, country, free text) | +| `get_deal_detail` | Full detail of a single deal | +| `deal_statistics` | Aggregated stats grouped by stage, industry, year, month, country, etc. | +| `list_my_requests_for_help` | Requests for Help sent by your firm to the network | +| `list_incoming_requests` | Incoming RFH from other firms (filterable by unanswered) | +| `list_rfh_suggestions` | Suggestions submitted for a specific Request for Help | +| `rfh_statistics` | Aggregated RFH activity stats (response rates, top responders, etc.) | +| `list_members` | Network professionals (filter by sector, country, job title, firm) | +| `get_member_detail` | Full profile of a single network member | +| `list_deal_stages` | Available deal stages | +| `list_industries` | Industry classifications | +| `list_firms` | Firms visible to your account | + +--- + +## Example questions for Claude + +``` +"Show me all open Sell Side deals in the Automotive sector" + +"How many deals did we close in 2024, broken down by industry?" + +"Are there any Requests for Help we haven't answered yet?" + +"Give me the full detail of deal #142" + +"What shared network deals are in the Technology sector?" + +"Show deal statistics by month from 2023 to today" + +"Who in the network covers Healthcare deals in Germany?" + +"How is our firm performing on incoming Requests for Help?" +``` diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9714bd9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +mcp>=1.0.0 +httpx>=0.27.0 diff --git a/server.py b/server.py new file mode 100644 index 0000000..502b4cb --- /dev/null +++ b/server.py @@ -0,0 +1,574 @@ +#!/usr/bin/env python3 +""" +Cauldron Cloud — MCP Server +Connects Claude Desktop (or any MCP client) to the Cauldron portal REST API. + +Setup: + pip install mcp httpx + export CAULDRON_API_KEY=cldrn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + export CAULDRON_API_URL=https://cauldron.cloud/api # optional override + +Claude Desktop config (~/.config/Claude/claude_desktop_config.json on Linux, + ~/Library/Application Support/Claude/claude_desktop_config.json on Mac): +{ + "mcpServers": { + "cauldron": { + "command": "python", + "args": ["/path/to/cauldron-mcp/server.py"], + "env": { + "CAULDRON_API_KEY": "cldrn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + } +} +""" + +import os +import sys +import json +import httpx +import asyncio +from typing import Any +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp import types + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +API_KEY = os.environ.get("CAULDRON_API_KEY", "") +API_URL = os.environ.get("CAULDRON_API_URL", "https://cauldron.cloud/api").rstrip("/") + +if not API_KEY: + print( + "ERROR: CAULDRON_API_KEY environment variable is required.\n" + "Set it in your Claude Desktop MCP configuration.", + file=sys.stderr, + ) + sys.exit(1) + +# --------------------------------------------------------------------------- +# HTTP client helper +# --------------------------------------------------------------------------- + +def _headers() -> dict: + return {"X-API-Key": API_KEY, "Accept": "application/json"} + + +async def _get(path: str, params: dict | None = None) -> dict: + """Make a GET request to the Cauldron API and return the parsed JSON.""" + url = f"{API_URL}{path}" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url, headers=_headers(), params=params or {}) + try: + data = resp.json() + except Exception: + return {"success": False, "error": f"HTTP {resp.status_code}: non-JSON response"} + + if resp.status_code == 401: + return {"success": False, "error": "Invalid or revoked API key. Check CAULDRON_API_KEY."} + if resp.status_code == 403: + return {"success": False, "error": "Access denied: " + data.get("error", "")} + if resp.status_code == 404: + return {"success": False, "error": "Not found: " + data.get("error", "")} + if not resp.is_success: + return {"success": False, "error": data.get("error", f"HTTP {resp.status_code}")} + + return data + + +def _fmt(data: Any) -> str: + """Format API response as a readable string for the LLM.""" + if isinstance(data, (dict, list)): + return json.dumps(data, indent=2, ensure_ascii=False, default=str) + return str(data) + + +# --------------------------------------------------------------------------- +# MCP Server +# --------------------------------------------------------------------------- + +app = Server("cauldron-cloud") + + +@app.list_tools() +async def list_tools() -> list[types.Tool]: + return [ + + types.Tool( + name="get_my_profile", + description=( + "Returns information about the authenticated user: name, email, " + "role (profile), and the firm they belong to. " + "Use this to understand the current user's context before answering questions." + ), + inputSchema={"type": "object", "properties": {}, "required": []}, + ), + + types.Tool( + name="list_deals", + description=( + "Lists M&A and financing deals visible to the user. " + "Only returns active deals (deleted ones are excluded). " + "A user sees deals from their own firm plus all shared deals from the network.\n\n" + "Use 'status=open' for ongoing deals (Pitch/Mandate/Signed/On-hold). " + "Use 'status=closed' for completed or lost deals.\n\n" + "deal_type examples: 'Sell Side', 'Buy Side', 'Financing', 'ECM', 'DCM', 'Fairness Opinion'.\n" + "For deals where a company is SELLING and looking for a buyer → deal_type='Sell Side'.\n" + "For deals where a client wants to ACQUIRE a company → deal_type='Buy Side'.\n" + "industry examples: 'Automotive', 'Technology', 'Healthcare', 'Consumer'.\n" + "stage examples: 'Pitch', 'Mandate', 'Signed', 'Closed', 'Lost', 'On-hold'." + ), + inputSchema={ + "type": "object", + "properties": { + "deal_type": { + "type": "string", + "description": "Filter by deal type (partial match). E.g. 'Sell Side', 'Buy Side', 'Financing'.", + }, + "stage": { + "type": "string", + "description": "Filter by deal stage (partial match). E.g. 'Mandate', 'Closed', 'Pitch'.", + }, + "status": { + "type": "string", + "enum": ["open", "closed", "all"], + "description": "'open' = Pitch/Mandate/Signed/On-hold; 'closed' = Closed/Lost; 'all' = no filter.", + "default": "all", + }, + "industry": { + "type": "string", + "description": "Filter by industry sector (partial match on Target/Seller/Buyer). E.g. 'Automotive', 'Tech'.", + }, + "country": { + "type": "string", + "description": "Filter by country (partial match on Target/Seller/Buyer country). E.g. 'Italy', 'Germany'.", + }, + "search": { + "type": "string", + "description": "Free-text search across TargetName, BuyerName, SellerName, MandateDescription.", + }, + "year_from": { + "type": "integer", + "description": "Filter deals created from this year onwards.", + }, + "year_to": { + "type": "integer", + "description": "Filter deals created up to this year.", + }, + "shared_only": { + "type": "boolean", + "description": "If true, return only deals shared across the network.", + }, + "limit": { + "type": "integer", + "description": "Max number of results (1–100, default 20).", + "default": 20, + }, + "offset": { + "type": "integer", + "description": "Pagination offset (default 0).", + "default": 0, + }, + }, + }, + ), + + types.Tool( + name="get_deal_detail", + description=( + "Returns the full details of a single deal by its ID, including " + "all parties (Target, Seller, Buyer), financial data (confidential values are masked), " + "stage, countries, industries, ownership types, contacts, and staff involved. " + "Use list_deals first to find the deal ID." + ), + inputSchema={ + "type": "object", + "properties": { + "deal_id": { + "type": "integer", + "description": "The numeric ID of the deal (IdDeal).", + } + }, + "required": ["deal_id"], + }, + ), + + types.Tool( + name="deal_statistics", + description=( + "Returns aggregated statistics on visible deals, grouped by a chosen dimension. " + "Useful for analytics questions like:\n" + "- 'How many deals did we close per year?'\n" + "- 'Which industry has the most deals?'\n" + "- 'What is our deal breakdown by stage?'" + ), + inputSchema={ + "type": "object", + "properties": { + "group_by": { + "type": "string", + "enum": ["stage", "industry", "deal_type", "year", "month", "firm", "country"], + "description": "Dimension to group by. 'month' = per month (YYYY-MM). 'country' = target country. Default: 'stage'.", + "default": "stage", + }, + "year_from": { + "type": "integer", + "description": "Include deals from this year onwards.", + }, + "year_to": { + "type": "integer", + "description": "Include deals up to this year.", + }, + }, + }, + ), + + types.Tool( + name="list_my_requests_for_help", + description=( + "Lists Request for Help (RFH) that MY FIRM has sent to the network. " + "These are requests we created asking other firms for buy-side leads or suggestions. " + "Shows how many responses each request has received.\n\n" + "Use status='open' for pending requests, 'closed' for completed ones." + ), + inputSchema={ + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["open", "closed", "all"], + "description": "Filter by request status. Default: 'all'.", + "default": "all", + }, + "deal_id": { + "type": "integer", + "description": "Optional: filter by a specific deal ID.", + }, + }, + }, + ), + + types.Tool( + name="list_incoming_requests", + description=( + "Lists Request for Help (RFH) sent by OTHER firms that are directed to MY FIRM, " + "asking us for buy-side leads or suggestions. " + "By default returns only requests we haven't answered yet.\n\n" + "Example use: 'Show me the requests for help we haven't responded to yet'\n" + "Example use: 'Are there any open RFH we should answer?'" + ), + inputSchema={ + "type": "object", + "properties": { + "unanswered_only": { + "type": "boolean", + "description": "If true (default), returns only requests with no response from our firm yet.", + "default": True, + }, + "status": { + "type": "string", + "enum": ["open", "closed", "all"], + "description": "Filter by request status. Default: 'open'.", + "default": "open", + }, + }, + }, + ), + + types.Tool( + name="list_deal_stages", + description="Returns the list of available deal stages (Pitch, Mandate, Signed, Closed, On-hold, Lost).", + inputSchema={"type": "object", "properties": {}, "required": []}, + ), + + types.Tool( + name="list_industries", + description="Returns the list of industry classifications available on the platform. Optionally filter by sector.", + inputSchema={ + "type": "object", + "properties": { + "sector": { + "type": "string", + "description": "Optional partial match on the sector name.", + } + }, + }, + ), + + types.Tool( + name="list_firms", + description=( + "Returns the firms visible to the current user: " + "their own firm plus all firms that have at least one deal shared with the network." + ), + inputSchema={"type": "object", "properties": {}, "required": []}, + ), + + types.Tool( + name="list_members", + description=( + "Lists people (professionals) visible to the current user: members of their own firm " + "plus members of other firms that have at least one shared deal in the network.\n\n" + "Useful for questions like:\n" + "- 'Who covers Automotive deals in Germany?'\n" + "- 'List all partners in the network'\n" + "- 'Who are the directors at [FirmName]?'\n" + "- 'Find me someone who covers Healthcare in Italy'\n\n" + "Each member includes their firm, job title, city, sectors they cover, and countries." + ), + inputSchema={ + "type": "object", + "properties": { + "search": { + "type": "string", + "description": "Free-text search on name or email.", + }, + "firm_id": { + "type": "integer", + "description": "Filter by firm ID (use list_firms to get IDs).", + }, + "job_title": { + "type": "string", + "description": "Partial match on job title. E.g. 'Partner', 'Director', 'Associate'.", + }, + "staff_category": { + "type": "string", + "description": "Partial match on staff category. E.g. 'partners', 'directors', 'associates'.", + }, + "sector": { + "type": "string", + "description": "Filter by industry sector covered. E.g. 'Automotive', 'Healthcare', 'Technology'.", + }, + "country": { + "type": "string", + "description": "Filter by country of activity. E.g. 'Italy', 'Germany', 'France'.", + }, + "limit": { + "type": "integer", + "description": "Max results (1–100, default 30).", + "default": 30, + }, + "offset": { + "type": "integer", + "description": "Pagination offset (default 0).", + "default": 0, + }, + }, + }, + ), + + types.Tool( + name="get_member_detail", + description=( + "Returns the full profile of a single network member by their ID, including " + "job title, city, phone numbers, firm, all sectors covered, countries of activity, " + "and their professional summary." + ), + inputSchema={ + "type": "object", + "properties": { + "member_id": { + "type": "integer", + "description": "The numeric ID of the member (from list_members).", + } + }, + "required": ["member_id"], + }, + ), + + types.Tool( + name="list_rfh_suggestions", + description=( + "Returns all suggestions (buy-side leads / candidates) submitted for a specific " + "Request for Help (RFH).\n\n" + "If the RFH was created by YOUR firm: you see all suggestions from all responding firms.\n" + "If it's an incoming RFH: you see only your firm's own submissions.\n\n" + "Each suggestion includes: suggesting firm, status (New/Go/No Go/Read), " + "whether it was selected, company info, financials (Turnover, EBITDA), and reasoning.\n\n" + "Use list_my_requests_for_help or list_incoming_requests to find RFH IDs first." + ), + inputSchema={ + "type": "object", + "properties": { + "rfh_id": { + "type": "integer", + "description": "The ID of the Request for Help (IdRequestForHelp).", + } + }, + "required": ["rfh_id"], + }, + ), + + types.Tool( + name="rfh_statistics", + description=( + "Returns aggregated statistics on Requests for Help (RFH) activity for the current firm.\n\n" + "Covers two dimensions:\n" + "1. **Outgoing RFH** (requests my firm sent): how many, open vs closed, " + "how many received no response, average suggestions received, " + "breakdown by suggestion status (Go/No Go/New/Read), selected suggestions, " + "and which firms in the network respond most actively.\n\n" + "2. **Incoming RFH** (requests from other firms targeting my firm): " + "how many we've been asked, how many we haven't answered yet, " + "our response rate, average days to respond, " + "and breakdown of our suggestions by status.\n\n" + "Example questions:\n" + "- 'How are our Requests for Help performing?'\n" + "- 'Which firms send us the most suggestions?'\n" + "- 'How many incoming RFH have we not answered yet?'\n" + "- 'What is our average response time on incoming requests?'" + ), + inputSchema={"type": "object", "properties": {}, "required": []}, + ), + ] + + +@app.call_tool() +async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: + """Dispatch tool calls to the Cauldron API.""" + + async def respond(result: dict) -> list[types.TextContent]: + if not result.get("success", True): + text = f"Error: {result.get('error', 'Unknown error')}" + else: + data = result.get("data", result) + meta = result.get("meta", {}) + parts = [] + if meta: + summary_parts = [] + if "total" in meta: + summary_parts.append(f"Total: {meta['total']}, returned: {meta.get('returned', '?')}") + if "firm" in meta: + summary_parts.append(f"Firm: {meta['firm']}") + if "group_by" in meta: + summary_parts.append(f"Grouped by: {meta['group_by']}") + if "unanswered_only" in meta: + summary_parts.append(f"Unanswered only: {meta['unanswered_only']}") + if summary_parts: + parts.append("# " + " | ".join(summary_parts)) + parts.append(_fmt(data)) + text = "\n".join(parts) + return [types.TextContent(type="text", text=text)] + + # ------------------------------------------------------------------ + if name == "get_my_profile": + result = await _get("/me") + return await respond(result) + + # ------------------------------------------------------------------ + elif name == "list_deals": + params: dict[str, Any] = {} + for key in ("deal_type", "stage", "status", "industry", "country", "search"): + if arguments.get(key): + params[key] = arguments[key] + for key in ("year_from", "year_to", "limit", "offset"): + if arguments.get(key) is not None: + params[key] = int(arguments[key]) + if arguments.get("shared_only"): + params["shared_only"] = "1" + result = await _get("/deals", params) + return await respond(result) + + # ------------------------------------------------------------------ + elif name == "get_deal_detail": + deal_id = int(arguments["deal_id"]) + result = await _get(f"/deal/{deal_id}") + return await respond(result) + + # ------------------------------------------------------------------ + elif name == "deal_statistics": + params = {} + if arguments.get("group_by"): + params["group_by"] = arguments["group_by"] + if arguments.get("year_from"): + params["year_from"] = int(arguments["year_from"]) + if arguments.get("year_to"): + params["year_to"] = int(arguments["year_to"]) + result = await _get("/stats", params) + return await respond(result) + + # ------------------------------------------------------------------ + elif name == "list_my_requests_for_help": + params = {} + if arguments.get("status"): + params["status"] = arguments["status"] + if arguments.get("deal_id"): + params["deal_id"] = int(arguments["deal_id"]) + result = await _get("/my-requests", params) + return await respond(result) + + # ------------------------------------------------------------------ + elif name == "list_incoming_requests": + params: dict[str, Any] = {} + unanswered = arguments.get("unanswered_only", True) + params["unanswered_only"] = "0" if unanswered is False else "1" + if arguments.get("status"): + params["status"] = arguments["status"] + result = await _get("/incoming-requests", params) + return await respond(result) + + # ------------------------------------------------------------------ + elif name == "list_deal_stages": + result = await _get("/stages") + return await respond(result) + + # ------------------------------------------------------------------ + elif name == "list_industries": + params = {} + if arguments.get("sector"): + params["sector"] = arguments["sector"] + result = await _get("/industries", params) + return await respond(result) + + # ------------------------------------------------------------------ + elif name == "list_firms": + result = await _get("/firms") + return await respond(result) + + # ------------------------------------------------------------------ + elif name == "list_members": + params: dict[str, Any] = {} + for key in ("search", "job_title", "staff_category", "sector", "country"): + if arguments.get(key): + params[key] = arguments[key] + for key in ("firm_id", "limit", "offset"): + if arguments.get(key) is not None: + params[key] = int(arguments[key]) + result = await _get("/members", params) + return await respond(result) + + # ------------------------------------------------------------------ + elif name == "get_member_detail": + member_id = int(arguments["member_id"]) + result = await _get(f"/member/{member_id}") + return await respond(result) + + # ------------------------------------------------------------------ + elif name == "list_rfh_suggestions": + rfh_id = int(arguments["rfh_id"]) + result = await _get(f"/rfh/{rfh_id}/suggestions") + return await respond(result) + + # ------------------------------------------------------------------ + elif name == "rfh_statistics": + result = await _get("/rfh-stats") + return await respond(result) + + # ------------------------------------------------------------------ + else: + return [types.TextContent(type="text", text=f"Unknown tool: {name}")] + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +async def main(): + async with stdio_server() as (read_stream, write_stream): + await app.run(read_stream, write_stream, app.create_initialization_options()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..439c811 --- /dev/null +++ b/setup.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" +Cauldron Cloud MCP — Setup wizard +Installs dependencies and writes Claude Desktop config automatically. +""" + +import json +import os +import platform +import shutil +import subprocess +import sys +import webbrowser +from pathlib import Path + +# ── Colours ────────────────────────────────────────────────────────────────── + +def _supports_colour() -> bool: + return sys.stdout.isatty() and platform.system() != "Windows" + +if _supports_colour(): + RESET = "\033[0m" + BOLD = "\033[1m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + RED = "\033[31m" + CYAN = "\033[36m" +else: + RESET = BOLD = GREEN = YELLOW = RED = CYAN = "" + +def ok(msg: str) -> None: print(f"{GREEN} ✓ {RESET}{msg}") +def info(msg: str) -> None: print(f"{CYAN} → {RESET}{msg}") +def warn(msg: str) -> None: print(f"{YELLOW} ⚠ {RESET}{msg}") +def err(msg: str) -> None: print(f"{RED} ✗ {RESET}{msg}") +def bold(msg: str) -> str: return f"{BOLD}{msg}{RESET}" + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _python_executable() -> str: + """Return the Python executable that is running this script.""" + return sys.executable + + +def _config_path() -> Path: + system = platform.system() + if system == "Windows": + base = Path(os.environ.get("APPDATA", "~")).expanduser() + return base / "Claude" / "claude_desktop_config.json" + if system == "Darwin": + return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json" + # Linux / other + xdg = os.environ.get("XDG_CONFIG_HOME", "") + base = Path(xdg) if xdg else Path.home() / ".config" + return base / "Claude" / "claude_desktop_config.json" + + +def _read_config(path: Path) -> dict: + if path.exists(): + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + warn("Existing config file contains invalid JSON — it will be backed up and rewritten.") + backup = path.with_suffix(".json.bak") + shutil.copy(path, backup) + info(f"Backup saved to {backup}") + return {} + + +def _write_config(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + + +def _install_deps() -> bool: + packages = ["mcp>=1.0.0", "httpx>=0.27.0"] + info("Installing Python dependencies...") + result = subprocess.run( + [_python_executable(), "-m", "pip", "install", "--quiet", *packages], + capture_output=True, + text=True, + ) + if result.returncode != 0: + err("pip install failed:") + print(result.stderr.strip()) + return False + return True + + +def _check_deps() -> bool: + try: + import mcp # noqa: F401 + import httpx # noqa: F401 + return True + except ImportError: + return False + + +def _validate_key(key: str) -> bool: + key = key.strip() + return key.startswith("cldrn_") and len(key) >= 20 + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main() -> None: + print() + print(bold("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) + print(bold(" Cauldron Cloud — MCP Server Setup")) + print(bold("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) + print() + + # ── 1. Python version check ─────────────────────────────────────────────── + if sys.version_info < (3, 10): + err(f"Python 3.10+ required (you have {platform.python_version()}).") + sys.exit(1) + ok(f"Python {platform.python_version()}") + + # ── 2. Dependencies ─────────────────────────────────────────────────────── + if _check_deps(): + ok("Dependencies already installed (mcp, httpx)") + else: + if not _install_deps(): + err("Could not install dependencies. Try manually: pip install mcp httpx") + sys.exit(1) + if not _check_deps(): + err("Dependencies installed but import still fails. Check your Python environment.") + sys.exit(1) + ok("Dependencies installed (mcp, httpx)") + + # ── 3. Locate server.py ─────────────────────────────────────────────────── + server_py = Path(__file__).resolve().parent / "server.py" + if not server_py.exists(): + err(f"server.py not found at {server_py}") + sys.exit(1) + ok(f"Server script: {server_py}") + + # ── 4. Get the API key ──────────────────────────────────────────────────── + print() + print(bold(" Step: Generate your API key")) + print() + print(" A browser window will open to the Cauldron portal.") + print(" Log in, click Generate , copy the key and paste it below.") + print() + input(" Press ENTER to open the browser...") + + portal_url = "https://cauldron.cloud/mcpKeys" + webbrowser.open(portal_url) + info(f"Opened: {portal_url}") + print() + + api_key = "" + for attempt in range(3): + raw = input(" Paste your API key here: ").strip() + if _validate_key(raw): + api_key = raw + break + err("Key must start with cldrn_ and be at least 20 characters. Try again.") + else: + err("No valid key provided. Run setup.py again when you have a key.") + sys.exit(1) + + ok(f"Key accepted: {api_key[:14]}…") + + # ── 5. Detect Claude Desktop config ─────────────────────────────────────── + config_path = _config_path() + print() + info(f"Config file: {config_path}") + + config = _read_config(config_path) + + # ── 6. Merge mcpServers entry ───────────────────────────────────────────── + python_cmd = _python_executable() + + mcp_entry = { + "command": python_cmd, + "args": [str(server_py)], + "env": {"CAULDRON_API_KEY": api_key}, + } + + if "mcpServers" not in config: + config["mcpServers"] = {} + + existing = config["mcpServers"].get("cauldron") + config["mcpServers"]["cauldron"] = mcp_entry + + _write_config(config_path, config) + + if existing: + ok("Updated existing cauldron entry in Claude Desktop config") + else: + ok("Added cauldron entry to Claude Desktop config") + + # ── 7. Done ─────────────────────────────────────────────────────────────── + print() + print(bold("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) + print(bold(" Setup complete!")) + print(bold("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) + print() + print(" Next steps:") + print(f" 1. {bold('Restart Claude Desktop')}") + print(" 2. Open a new Chat") + print(" 3. Look for the 🔌 icon — cauldron should appear in the tool list") + print() + print(" Try asking Claude:") + print(f" {CYAN}\"Show me all open Sell Side deals in the Automotive sector\"{RESET}") + print(f" {CYAN}\"Are there any incoming Requests for Help we haven't answered?\"{RESET}") + print() + + +if __name__ == "__main__": + main()