mikeboone Claude Opus 4.8 commited on
Commit
5844a76
Β·
1 Parent(s): e769a68

feat: MCP server + HF Docker Space config for AgentSpot

Browse files

mcp_server.py exposes the demo-build pipeline as MCP tools (ping,
build_demo_from_brief). Thin adapter over the existing controller;
streamable-HTTP with a pure-ASGI bearer gate + unauthenticated health
route. Dockerfile/README target an HF Docker Space (deploy branch only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (3) hide show
  1. Dockerfile +20 -30
  2. README.md +28 -157
  3. mcp_server.py +318 -0
Dockerfile CHANGED
@@ -1,39 +1,29 @@
1
- # ThoughtSpot Demo Builder - Docker Deployment
2
- # Use this for Railway.app, Render.com, or self-hosted deployment
3
-
4
- FROM python:3.10-slim
5
-
6
- # Set working directory
 
 
 
 
 
 
7
  WORKDIR /app
8
 
9
- # Install system dependencies
10
- RUN apt-get update && apt-get install -y \
11
- git \
12
- curl \
13
- && rm -rf /var/lib/apt/lists/*
14
-
15
- # Copy requirements first for better caching
16
- COPY requirements.txt .
17
 
18
- # Install Python dependencies
19
  RUN pip install --no-cache-dir -r requirements.txt
20
 
21
- # Copy application code
22
- COPY . .
23
 
24
- # Create necessary directories
25
- RUN mkdir -p results demo_logs
 
 
26
 
27
- # Set environment variables for Gradio
28
- ENV GRADIO_SERVER_NAME="0.0.0.0"
29
- ENV GRADIO_SERVER_PORT="7860"
30
-
31
- # Expose the Gradio port
32
  EXPOSE 7860
33
 
34
- # Health check
35
- HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
36
- CMD curl -f http://localhost:7860/ || exit 1
37
-
38
- # Run the application
39
- CMD ["python", "app.py"]
 
1
+ # Dockerfile β€” DemoPrep MCP Space (Hugging Face Docker Space)
2
+ #
3
+ # Runs the MCP server (mcp_server.py) over streamable-HTTP on port 7860.
4
+ # This is a SEPARATE Space from the Gradio app: same codebase, different
5
+ # entrypoint. Business logic stays in one place (demoprep_app/) β€” this Space
6
+ # just runs the MCP adapter instead of the Gradio UI.
7
+ #
8
+ # Required secrets: set in Space Settings -> Repository Secrets (see the
9
+ # mcp_server.py module docstring for the full list).
10
+ FROM python:3.11-slim
11
+
12
+ RUN useradd -m -u 1000 user
13
  WORKDIR /app
14
 
15
+ RUN apt-get update && apt-get install -y git curl && rm -rf /var/lib/apt/lists/*
 
 
 
 
 
 
 
16
 
17
+ COPY --chown=user requirements.txt .
18
  RUN pip install --no-cache-dir -r requirements.txt
19
 
20
+ COPY --chown=user . .
 
21
 
22
+ USER user
23
+ ENV MCP_TRANSPORT=http \
24
+ MCP_HTTP_PORT=7860 \
25
+ HOME=/home/user
26
 
 
 
 
 
 
27
  EXPOSE 7860
28
 
29
+ CMD ["python", "mcp_server.py"]
 
 
 
 
 
README.md CHANGED
@@ -1,170 +1,41 @@
1
  ---
2
- title: ThoughtSpot Demo Builder
3
- emoji: πŸš€
4
  colorFrom: blue
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: "4.44.1"
8
- app_file: app.py
9
  pinned: false
10
  license: mit
11
- python_version: "3.11"
12
  ---
13
 
14
- # Demo Wire - AI-Powered Demo Builder
15
 
16
- A powerful Gradio-based application that automatically generates and deploys complete demo environments for ThoughtSpot, including Snowflake schemas, data population, and semantic model creation.
 
 
 
17
 
18
- > **Deployed on Hugging Face Spaces**: This app can be deployed to HF Spaces for easy access. See [DEPLOYMENT.md](DEPLOYMENT.md) for setup instructions.
 
 
19
 
20
- ## πŸš€ Features
 
 
 
21
 
22
- - **AI-Powered Research**: Automatically researches companies and industries using LLM
23
- - **Schema Generation**: Creates optimized Snowflake DDL based on business context
24
- - **Data Population**: Generates realistic demo data with strategic outliers
25
- - **ThoughtSpot Integration**: Deploys connections, tables, and semantic models
26
- - **Interactive UI**: Clean, intuitive Gradio interface with real-time progress updates
27
 
28
- ## πŸ› οΈ Tech Stack
29
 
30
- - **Frontend**: Gradio (Python web UI)
31
- - **Backend**: Python 3.12+
32
- - **Database**: Snowflake
33
- - **Analytics**: ThoughtSpot
34
- - **AI**: OpenAI GPT models
35
- - **Data Generation**: Faker library
 
 
36
 
37
- ## πŸ“‹ Prerequisites
38
-
39
- - Python 3.12+
40
- - Snowflake account with appropriate permissions
41
- - ThoughtSpot Cloud account
42
- - OpenAI API key
43
-
44
- ## πŸš€ Quick Start
45
-
46
- 1. **Clone the repository**
47
- ```bash
48
- git clone https://github.com/yourusername/demo-wire.git
49
- cd demo-wire
50
- ```
51
-
52
- 2. **Set up virtual environment**
53
- ```bash
54
- python -m venv demo_wire
55
- source demo_wire/bin/activate # On Windows: demo_wire\Scripts\activate
56
- ```
57
-
58
- 3. **Install dependencies**
59
- ```bash
60
- pip install -r requirements.txt
61
- ```
62
-
63
- 4. **Configure environment variables**
64
- ```bash
65
- cp .env.example .env
66
- # Edit .env with your credentials
67
- ```
68
-
69
- 5. **Run the application**
70
- ```bash
71
- python demo_prep.py
72
- ```
73
-
74
- 6. **Open your browser**
75
- Navigate to `http://localhost:7860`
76
-
77
- ## βš™οΈ Configuration
78
-
79
- Create a `.env` file with the following variables:
80
-
81
- ```env
82
- # OpenAI
83
- OPENAI_API_KEY=your_openai_api_key
84
-
85
- # Snowflake
86
- SNOWFLAKE_USER=your_username
87
- SNOWFLAKE_PASSWORD=your_password
88
- SNOWFLAKE_ACCOUNT=your_account
89
- SNOWFLAKE_WAREHOUSE=your_warehouse
90
- SNOWFLAKE_DATABASE=your_database
91
- SNOWFLAKE_SCHEMA=your_schema
92
-
93
- # ThoughtSpot
94
- THOUGHTSPOT_URL=your_thoughtspot_url
95
- THOUGHTSPOT_USERNAME=your_username
96
- THOUGHTSPOT_PASSWORD=your_password
97
-
98
- # Slack deployment notifications (optional, outbound-only)
99
- SLACK_BOT_TOKEN=xoxb-your_bot_token
100
- SLACK_DEPLOYMENT_CHANNEL_ID=C0123456789
101
- ```
102
-
103
- Slack notifications use the Slack Web API to post deployment status messages from
104
- DemoPrep into one approved channel. This path is outbound-only: no Socket Mode,
105
- event subscriptions, slash commands, or public Slack request URL are required.
106
- The Slack app only needs the `chat:write` bot scope, and the bot must be invited
107
- to the target channel.
108
-
109
- ## 🎯 Usage
110
-
111
- 1. **Enter Company Information**: Company name, URL, and industry
112
- 2. **Start Research**: AI analyzes the company and industry
113
- 3. **Create DDL**: Generate optimized Snowflake schema
114
- 4. **Generate Data**: Create realistic demo data with outliers
115
- 5. **Deploy**: Deploy to Snowflake and ThoughtSpot
116
-
117
- ## πŸ“ Project Structure
118
-
119
- ```
120
- demo-wire/
121
- β”œβ”€β”€ demo_prep.py # Main Gradio application
122
- β”œβ”€β”€ schema_utils.py # Schema parsing and generation utilities
123
- β”œβ”€β”€ thoughtspot_deployer.py # ThoughtSpot deployment logic
124
- β”œβ”€β”€ snowflake_auth.py # Snowflake authentication
125
- β”œβ”€β”€ demo_personas.py # Demo persona configurations
126
- β”œβ”€β”€ prompts.py # LLM prompt templates
127
- β”œβ”€β”€ requirements.txt # Python dependencies
128
- β”œβ”€β”€ docs/ # Documentation
129
- β”œβ”€β”€ tests/ # Test files
130
- └── results/ # Generated demo results
131
- ```
132
-
133
- ## πŸ§ͺ Testing
134
-
135
- Run the test suite:
136
-
137
- ```bash
138
- python -m pytest tests/
139
- ```
140
-
141
- ## 🀝 Contributing
142
-
143
- 1. Fork the repository
144
- 2. Create a feature branch (`git checkout -b feature/amazing-feature`)
145
- 3. Commit your changes (`git commit -m 'Add amazing feature'`)
146
- 4. Push to the branch (`git push origin feature/amazing-feature`)
147
- 5. Open a Pull Request
148
-
149
- ## πŸ“„ License
150
-
151
- This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
152
-
153
- ## πŸ™ Acknowledgments
154
-
155
- - ThoughtSpot for the analytics platform
156
- - Snowflake for the data warehouse
157
- - OpenAI for the AI capabilities
158
- - Gradio for the web interface
159
-
160
- ## πŸ“š Development Notes
161
-
162
- Development notes and sprint planning are stored in `dev_notes/` (not committed to version control).
163
-
164
- ## πŸ“ž Support
165
-
166
- For support, email support@demo-wire.com or create an issue in this repository.
167
-
168
- ---
169
-
170
- **Built with ❀️ for the ThoughtSpot community**
 
1
  ---
2
+ title: DemoPrep MCP
3
+ emoji: πŸ”Œ
4
  colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
 
8
  pinned: false
9
  license: mit
 
10
  ---
11
 
12
+ # DemoPrep MCP Server
13
 
14
+ An MCP (Model Context Protocol) server that exposes DemoPrep's demo-build pipeline
15
+ as tools an external agent (ThoughtSpot **AgentSpot**) can call β€” building a full
16
+ demo (Snowflake schema β†’ ThoughtSpot model β†’ liveboard) from a prospect **brief**,
17
+ with no human clicking through the Gradio UI.
18
 
19
+ - **Transport:** streamable-HTTP at `/mcp`, gated by `Authorization: Bearer <MCP_ACCESS_TOKEN>`.
20
+ - **Health:** unauthenticated `GET /` returns `{"status":"ok"}`.
21
+ - **Tools:** `ping` (connectivity check) and `build_demo_from_brief` (full build).
22
 
23
+ This Space shares the DemoPrep codebase with the app Space but runs the MCP
24
+ entrypoint (`mcp_server.py`) instead of the Gradio app β€” the business logic lives
25
+ in one place (`demoprep_app/` + the controller). Builds run for minutes, so the
26
+ tool is meant to be driven job-style (fire, then check back).
27
 
28
+ ## Required secrets
 
 
 
 
29
 
30
+ Set these in **Settings β†’ Repository Secrets** (nothing sensitive is committed):
31
 
32
+ | Secret | Purpose |
33
+ |--------|---------|
34
+ | `SUPABASE_URL`, `SUPABASE_ANON_KEY` | bootstrap β†’ pulls Snowflake + LLM keys from admin settings |
35
+ | `OPENAI_API_KEY` | LLM key (env-only) |
36
+ | `TS_ENV_1_LABEL`, `TS_ENV_1_URL`, `TS_ENV_1_KEY_VAR` | the target ThoughtSpot environment |
37
+ | `MCP_OWNER_EMAIL` | TS user that owns created objects (trusted-auth) |
38
+ | `MCP_TS_ENV_LABEL` | which TS env label to deploy into |
39
+ | `MCP_ACCESS_TOKEN` | shared bearer secret the calling agent presents |
40
 
41
+ `MCP_TRANSPORT=http` and `MCP_HTTP_PORT=7860` are baked into the Dockerfile.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mcp_server.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ mcp_server.py β€” Demo Wire MCP server (v1)
3
+
4
+ Exposes DemoPrep's demo-build pipeline as an MCP tool so an external agent
5
+ (AgentSpot / Spotter Assistant) can build a full ThoughtSpot demo from a prospect
6
+ *brief* without a human clicking through the Gradio UI.
7
+
8
+ This is a THIN ADAPTER. It does not reimplement any business logic β€” it drives the
9
+ exact same `ChatDemoInterface` controller the Gradio app uses, headlessly, skipping
10
+ the research phase by injecting the brief as the research output. All build logic
11
+ stays in one place (see docs/SINGLE_PIPELINE.md and CLAUDE.md).
12
+
13
+ v1 scope (simplest thing that works):
14
+ * ONE blocking tool β€” build_demo_from_brief(...) runs the whole pipeline
15
+ (blueprint -> dataset -> DDL -> Snowflake -> TS model -> liveboard) and returns
16
+ the resulting IDs when done.
17
+ * Everything is fixed/server-side; per-call config (env, model, sharing) is a
18
+ later version.
19
+ * Builds run for minutes. If the caller's connection times out, the build still
20
+ finishes server-side and DemoPrep's Slack notification delivers the liveboard
21
+ URL. (Async / job-backed delivery is the planned next step.)
22
+
23
+ Server-side config (env vars β€” fail loud if a required one is missing):
24
+ MCP_OWNER_EMAIL (required) TS user that owns created objects (trusted-auth)
25
+ MCP_TS_ENV_LABEL (required) which TS_ENV_<n>_LABEL from .env to deploy into
26
+ MCP_TRANSPORT "stdio" (default, local dev) | "http" (HF Space)
27
+ MCP_HTTP_PORT http port (default 7860)
28
+ MCP_ACCESS_TOKEN (required in http mode) shared bearer secret gating the endpoint
29
+
30
+ Local smoke test (needs the project venv with requirements installed):
31
+ MCP_OWNER_EMAIL=you@thoughtspot.com MCP_TS_ENV_LABEL=SE python mcp_server.py
32
+ Then point an MCP client (e.g. `mcp dev mcp_server.py` / MCP Inspector) at stdio.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import os
38
+ import sys
39
+ import time
40
+ import types
41
+ import uuid
42
+ import threading
43
+
44
+ # --- env bootstrap: order matters --------------------------------------------
45
+ # .env first so MCP_* / TS_ENV_* / Supabase creds are available.
46
+ from dotenv import load_dotenv
47
+ load_dotenv()
48
+
49
+ # Fixed owner identity for v1 (per-user "everyone as themselves" is a later
50
+ # version). No silent fallback β€” user identity is required (see CLAUDE.md).
51
+ _OWNER_EMAIL = (os.getenv("MCP_OWNER_EMAIL") or "").strip()
52
+ if not _OWNER_EMAIL:
53
+ raise RuntimeError(
54
+ "MCP_OWNER_EMAIL is required β€” the ThoughtSpot user that owns created objects."
55
+ )
56
+ # The controller resolves the acting user from these when there's no login cookie.
57
+ os.environ.setdefault("DEMOPREP_NO_AUTH", "true")
58
+ os.environ.setdefault("DEMOPREP_DEV_USER_EMAIL", _OWNER_EMAIL)
59
+
60
+ _TS_ENV_LABEL = (os.getenv("MCP_TS_ENV_LABEL") or "").strip()
61
+ if not _TS_ENV_LABEL:
62
+ raise RuntimeError(
63
+ "MCP_TS_ENV_LABEL is required β€” which TS_ENV_<n>_LABEL from .env to deploy into."
64
+ )
65
+
66
+ # Snowflake creds + SNOWFLAKE_DATABASE from Supabase admin settings -> os.environ.
67
+ # Must run once at startup; the deploy path reads these via get_admin_setting/env.
68
+ from supabase_client import inject_admin_settings_to_env
69
+ inject_admin_settings_to_env()
70
+
71
+ # Heavy imports (pull gradio etc.) β€” safe headless. DEMOPREP_NO_AUTH is set above,
72
+ # which must happen BEFORE importing chat_interface.
73
+ from chat_interface import ChatDemoInterface, get_ts_env_url, get_ts_env_auth_key
74
+ from demo_builder_class import DemoBuilder
75
+ from demo_personas import parse_use_case, get_use_case_config
76
+ from llm_config import DEFAULT_LLM_MODEL
77
+
78
+ # Resolve the fixed ThoughtSpot environment once (light β€” reads .env only).
79
+ _TS_URL = get_ts_env_url(_TS_ENV_LABEL)
80
+ _TS_AUTH_KEY = get_ts_env_auth_key(_TS_ENV_LABEL)
81
+ if not _TS_URL or not _TS_AUTH_KEY:
82
+ raise RuntimeError(
83
+ f"ThoughtSpot environment '{_TS_ENV_LABEL}' not found or incomplete in .env "
84
+ f"(need TS_ENV_<n>_LABEL={_TS_ENV_LABEL} with matching _URL and _KEY_VAR)."
85
+ )
86
+
87
+ from mcp.server.fastmcp import FastMCP
88
+
89
+ # Serialize-first: one build at a time. Protects the process-global os.environ
90
+ # writes (Snowflake account) that inject_admin_settings_to_env performs, and
91
+ # matches the decision to lift real concurrency later.
92
+ _build_lock = threading.Lock()
93
+
94
+ mcp = FastMCP("demoprep")
95
+
96
+
97
+ @mcp.tool()
98
+ def ping() -> dict:
99
+ """Health / connectivity check β€” returns server identity + config, no side effects.
100
+
101
+ Use this to confirm a client (e.g. AgentSpot) can reach and invoke the server
102
+ without triggering a full build.
103
+ """
104
+ return {
105
+ "ok": True,
106
+ "server": "demoprep-mcp v1",
107
+ "ts_environment": _TS_ENV_LABEL,
108
+ "ts_url": _TS_URL,
109
+ "owner_email": _OWNER_EMAIL,
110
+ "tools": ["ping", "build_demo_from_brief"],
111
+ }
112
+
113
+
114
+ def _run_build(brief: str, company_name: str, use_case: str, company_url: str) -> dict:
115
+ """Drive the controller headlessly. Mirrors tests/newvision_sample_runner.py but
116
+ injects `brief` in place of the research phase. Returns a structured dict and
117
+ never raises to the caller β€” failures come back as status 'failed' / 'partial'."""
118
+ run_id = uuid.uuid4().hex[:12]
119
+ started = time.time()
120
+
121
+ def result(status: str, dc: dict | None = None, schema: str | None = None,
122
+ error: str | None = None) -> dict:
123
+ dc = dc or {}
124
+ return {
125
+ "run_id": run_id,
126
+ "status": status, # success | partial | failed
127
+ "schema": dc.get("schema") or schema or "",
128
+ "model_guid": dc.get("model_guid", ""),
129
+ "liveboard_guid": dc.get("liveboard_guid", ""),
130
+ "model_url": dc.get("model_url", ""),
131
+ "liveboard_url": dc.get("liveboard_url", ""),
132
+ "ts_environment": _TS_ENV_LABEL,
133
+ "owner_email": _OWNER_EMAIL,
134
+ "warnings": dc.get("warnings", []),
135
+ "errors": ([error] if error else dc.get("errors", [])),
136
+ "elapsed_seconds": round(time.time() - started, 1),
137
+ }
138
+
139
+ controller = None
140
+ try:
141
+ # (i) controller
142
+ controller = ChatDemoInterface(user_email=_OWNER_EMAIL)
143
+
144
+ # (ii) settings: model + fixed TS env (exact key names per the wiring trace)
145
+ controller.settings["model"] = controller.settings.get("model") or DEFAULT_LLM_MODEL
146
+ controller.settings["thoughtspot_url"] = _TS_URL
147
+ controller.settings["thoughtspot_trusted_auth_key"] = _TS_AUTH_KEY
148
+
149
+ # vertical / function / use_case_config exactly as the runner does
150
+ controller.vertical, controller.function = parse_use_case(use_case or "")
151
+ controller.use_case_config = get_use_case_config(
152
+ controller.vertical or "Generic", controller.function or "Generic"
153
+ )
154
+
155
+ # (iii) demo_builder with the BRIEF injected in place of research.
156
+ db = DemoBuilder(use_case=use_case, company_url=company_url)
157
+ db.company_analysis_results = brief # component field
158
+ db.combined_research_results = brief # <-- the field build_demo actually reads
159
+ db.company_summary = brief # <-- liveboard Spotter story reads this (Gotcha 2)
160
+ # Force the exact display name β€” extract_company_name() otherwise parses the
161
+ # domain from company_url. Minimal shim standing in for a scraped website.
162
+ db.website_data = types.SimpleNamespace(
163
+ title=company_name, url=company_url, text="", css_links=[], logo_candidates=[]
164
+ )
165
+ controller.demo_builder = db
166
+ controller.generic_use_case_context = ""
167
+
168
+ # (iv) DDL β€” returns a (response, ddl) tuple; NOT a generator.
169
+ resp, ddl_text = controller.run_ddl_creation()
170
+ if not ddl_text or "CREATE TABLE" not in ddl_text.upper():
171
+ return result("failed", error=f"DDL generation failed: {str(resp)[:500]}")
172
+
173
+ # (v) Snowflake load, then ThoughtSpot. Both are generators β€” draining them
174
+ # IS what runs the work. Decoupled from validation_mode: drain the Snowflake
175
+ # generator, read the schema it set, then run the TS deploy ourselves.
176
+ for _ in controller.run_deployment_streaming():
177
+ pass
178
+ schema = getattr(controller, "_deployed_schema_name", None)
179
+ if not schema:
180
+ return result(
181
+ "failed",
182
+ schema=getattr(controller, "_last_schema_name", None),
183
+ error="Snowflake load did not complete (no deployed schema).",
184
+ )
185
+
186
+ for _ in controller._run_thoughtspot_deployment(schema, company_name, use_case):
187
+ pass
188
+
189
+ # (vi) structured result from the completion record.
190
+ dc = getattr(controller, "deployment_completion", None)
191
+ if not dc:
192
+ # deploy_all raised before the completion record was written β€” surface
193
+ # partial success: the Snowflake schema exists even if TS didn't finish.
194
+ return result(
195
+ "partial", schema=schema,
196
+ error="ThoughtSpot deploy did not complete; Snowflake schema exists.",
197
+ )
198
+ return result("success" if dc.get("success") else "partial", dc=dc, schema=schema)
199
+
200
+ except Exception as e: # never leak a raw exception to the MCP caller
201
+ schema = getattr(controller, "_deployed_schema_name", None) if controller else None
202
+ return result(
203
+ "partial" if schema else "failed",
204
+ schema=schema,
205
+ error=f"{type(e).__name__}: {e}",
206
+ )
207
+
208
+
209
+ @mcp.tool()
210
+ def build_demo_from_brief(
211
+ brief: str,
212
+ company_name: str,
213
+ use_case: str,
214
+ company_url: str = "",
215
+ ) -> dict:
216
+ """Build a complete ThoughtSpot demo from a prospect brief.
217
+
218
+ Skips DemoPrep's own research: the `brief` IS the research context. Runs the
219
+ full pipeline (dataset -> DDL -> Snowflake -> model -> liveboard) and returns
220
+ the resulting IDs. BLOCKS until the build finishes (several minutes); if your
221
+ connection times out, the build still completes server-side.
222
+
223
+ Args:
224
+ brief: Prospect narrative β€” pain points, what they're evaluating, industry
225
+ context, goals. Becomes the research context the demo is built from.
226
+ company_name: Display name for the demo (e.g. "Acme Corporation").
227
+ use_case: The analytics story / use case (e.g. "Retail Sales").
228
+ company_url: Optional company URL (used for context/branding; not scraped).
229
+
230
+ Returns:
231
+ dict: status (success|partial|failed|busy), schema, model_guid,
232
+ liveboard_guid, model_url, liveboard_url, ts_environment, owner_email,
233
+ warnings, errors, run_id, elapsed_seconds.
234
+ """
235
+ if not brief or not brief.strip():
236
+ return {"status": "failed", "errors": ["brief is required"]}
237
+ if not (company_name or "").strip() or not (use_case or "").strip():
238
+ return {"status": "failed", "errors": ["company_name and use_case are required"]}
239
+
240
+ # Serialize-first: reject overlapping builds rather than corrupting shared env.
241
+ if not _build_lock.acquire(blocking=False):
242
+ return {
243
+ "status": "busy",
244
+ "errors": ["A build is already running (v1 runs one at a time). Retry shortly."],
245
+ }
246
+ try:
247
+ return _run_build(
248
+ brief.strip(), company_name.strip(), use_case.strip(), (company_url or "").strip()
249
+ )
250
+ finally:
251
+ _build_lock.release()
252
+
253
+
254
+ def _run_http(token: str) -> None:
255
+ """Serve over streamable HTTP behind a shared-bearer gate.
256
+
257
+ The auth check is a PURE-ASGI wrapper, NOT Starlette's BaseHTTPMiddleware β€”
258
+ the latter buffers responses and breaks the streamable-HTTP SSE stream. This
259
+ checks the bearer on every HTTP request and otherwise passes the raw ASGI
260
+ through untouched.
261
+ """
262
+ import uvicorn
263
+
264
+ inner = mcp.streamable_http_app() # verified accessor on mcp 1.28.1
265
+
266
+ class _BearerGate:
267
+ def __init__(self, app):
268
+ self.app = app
269
+
270
+ async def __call__(self, scope, receive, send):
271
+ if scope.get("type") == "http":
272
+ path = scope.get("path", "")
273
+ if path in ("/", "/health"):
274
+ # Unauthenticated liveness check so the HF Space reports healthy.
275
+ # The MCP protocol itself lives at /mcp behind the bearer gate.
276
+ await send({
277
+ "type": "http.response.start",
278
+ "status": 200,
279
+ "headers": [(b"content-type", b"application/json")],
280
+ })
281
+ await send({"type": "http.response.body",
282
+ "body": b'{"status":"ok","service":"demoprep-mcp"}'})
283
+ return
284
+ headers = dict(scope.get("headers") or [])
285
+ if headers.get(b"authorization", b"").decode() != f"Bearer {token}":
286
+ await send({
287
+ "type": "http.response.start",
288
+ "status": 401,
289
+ "headers": [(b"content-type", b"application/json")],
290
+ })
291
+ await send({"type": "http.response.body", "body": b'{"error":"unauthorized"}'})
292
+ return
293
+ await self.app(scope, receive, send)
294
+
295
+ uvicorn.run(_BearerGate(inner), host="0.0.0.0", port=int(os.getenv("MCP_HTTP_PORT", "7860")))
296
+
297
+
298
+ def main() -> None:
299
+ transport = (os.getenv("MCP_TRANSPORT") or "stdio").strip().lower()
300
+ print(
301
+ f"[mcp_server] ready β€” ts_env='{_TS_ENV_LABEL}' url={_TS_URL} "
302
+ f"owner={_OWNER_EMAIL} transport={transport}",
303
+ file=sys.stderr, flush=True,
304
+ )
305
+
306
+ if transport in ("stdio", ""):
307
+ mcp.run(transport="stdio")
308
+ elif transport in ("http", "streamable-http", "streamable_http"):
309
+ token = (os.getenv("MCP_ACCESS_TOKEN") or "").strip()
310
+ if not token: # public endpoint must be gated
311
+ raise RuntimeError("MCP_ACCESS_TOKEN is required in http mode (public endpoint).")
312
+ _run_http(token)
313
+ else:
314
+ raise RuntimeError(f"Unknown MCP_TRANSPORT: {transport!r} (use 'stdio' or 'http').")
315
+
316
+
317
+ if __name__ == "__main__":
318
+ main()