From 8f9a47385ace17b91c17a3e463f30c688a14cc37 Mon Sep 17 00:00:00 2001 From: Mourya Balabhadra Date: Wed, 2 Sep 2026 03:58:11 -0700 Subject: [PATCH] SCAL-336134: Add developer examples related to chat history for spotter mcp server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary Brings the `python-react-agent-simple-ui` example up to date with the Spotter 3 MCP toolset, and fixes three things that made it unreliable to run: embed auth, chart rendering after an answer expires, and CSP-blocked iframes. ## Changes ### Spotter 3 naming - `claude_agent_mcp_server_v2.py` → `claude_agent_with_spotter3_mcp_server.py` - `claude_agent_mcp_server_v2_with_chat_history.py` → `claude_agent_with_spotter3_mcp_server_and_chat_history.py` - README and `env.template` re-worded off `v1`/`v2` onto Spotter 3 terms, and the OpenAI / Azure OpenAI (v1) documentation removed. ### Embed authentication — fixes the "Duplicate token" alert The client's `getAuthToken` returned one constant `VITE_TS_AUTH_TOKEN`. The SDK requires a *fresh* token per call: once that static token stops verifying, the SDK reports a duplicate token and the callback can never recover, because it hands back the same string. - New `GET /api/ts-token` on both servers, minting a short-lived token per request via `POST /api/rest/2.0/auth/token/full`. - Configured with `TS_EMBED_USERNAME` + `TS_SECRET_KEY` (or `TS_EMBED_PASSWORD`); `TS_TOKEN_VALIDITY_SEC` sets the lifetime, default 1800s. - With no minting credentials set, the endpoint falls back to the static token so the demo still runs, and logs why. - Client `getAuthToken` now fetches that endpoint, and `autoLogin: true` renews before expiry. No ThoughtSpot token is bundled into the frontend any more. ### Chart rendering after an answer expires A ThoughtSpot answer object lives ~8 hours. The chat history stored each answer's `iframe_url` and `answer_id` (which is really a `{session_id, gen_no}` pair), so reopening an older conversation rendered a row of dead embeds. - The server no longer persists those fields, and strips them on read too, so rows written before this change take the same path. - `GET /api/conversations/{id}` now returns each answer's `answer_index` plus the conversation's `analytical_session_id`; the client emits a resolver placeholder and the Visual Embed SDK resolves a live URL. **Requires the SDK change in PR 2.** - `reconcile_answers` asks `getConversation` how many answers each turn actually has, rather than trusting the stored copy. This also recovers answers from turns whose SSE stream was cut off mid-flight: the Agent finishes regardless, so the answer exists on ThoughtSpot's side even when the app recorded none of it. ### Raw session updates `TS_MCP_API_VERSION` now defaults to `latest`, and the update readers accept both the server's digested shape and the Agent's raw shape (`text-chunk` vs `text_chunk`, `metadata.type == "thinking"` vs `is_thinking`, …), so `&enable-raw-session-updates=true` can be turned on through `TS_MCP_URL` without a code change. Intermediate "thinking" answers are filtered out — one three-question chat produced eleven answer updates but a single settled one — which also keeps our answer ordering aligned with `getConversation`'s. ### CSP / ports The cluster's `frame-ancestors` allowlists `http://localhost:8000`, not Vite's default 5173, so the embed iframe was blocked and rendered as `chrome-error://chromewebdata/`. Vite now serves on 8000 with `strictPort`, and proxies `/api` to the backend on 8001. ### System theme `App.css` moved fully onto CSS custom properties with a `prefers-color-scheme: dark` block (no hardcoded colours left bypassing the tokens), and the embed itself gets matching dark `customizations` variables so the chart doesn't stay white inside a dark page. ### Misc - `AnswerFrame` removed: answer iframes are injected as markup so React owns only the wrapper and doesn't fight the renderer's `replaceWith()`. - Dependency bumps: `anthropic>=1.2.0,<2`, `mcp>=2.1.1,<3`, `httpx` → `httpx2`, FastAPI/uvicorn; `@thoughtspot/visual-embed-sdk` `1.45.3-mcp.2` → `1.51.1`. - `.gitignore`: local `*.db` / `-wal` / `-shm` chat-history files. ## Testing - Live chats through both servers: answers stream, embeds render, follow-up turns reuse the same analytical session. - `/api/ts-token` verified minting: `minted: true`, a different token per call, and the minted token authenticates against `/callosum/v1/session/isactive`. - `/api/conversations` list/open/delete exercised against the stored database. - Client `vite build` passes. Not verified: the expired-answer replay path against a genuinely >8h-old conversation. The cluster's REST layer was returning 502 during that window, so it has only been exercised via the SDK's unit tests and against live (unexpired) conversations. ## Merge order The past-chat replay depends on the `startAutoMCPFrameRenderer` change in PR 2. Local development currently works because `node_modules/@thoughtspot/visual-embed-sdk` is npm-linked to a local SDK checkout, but `package.json` pins `^1.51.1` from the registry, which does not include it. **Land and release the SDK change, then bump the pin here** — otherwise a fresh `npm install` will render past-chat answers as iframes with no session parameters. ## Notes `server/agent.py` (the OpenAI/Azure v1 backend) and the `openai` entry in `requirements.txt` are left in place but are no longer documented. Say if they should be removed. --- mcp/python-react-agent-simple-ui/README.md | 379 +++--- .../client/package-lock.json | 32 +- .../client/package.json | 2 +- .../client/src/App.css | 157 ++- .../client/src/App.jsx | 481 +++++-- .../client/vite.config.js | 7 +- mcp/python-react-agent-simple-ui/env.template | 40 +- .../server/claude_agent_mcp_server_v2.py | 263 ---- .../claude_agent_with_spotter3_mcp_server.py | 675 ++++++++++ ...th_spotter3_mcp_server_and_chat_history.py | 1111 +++++++++++++++++ .../server/requirements.txt | 10 +- 11 files changed, 2622 insertions(+), 535 deletions(-) delete mode 100644 mcp/python-react-agent-simple-ui/server/claude_agent_mcp_server_v2.py create mode 100644 mcp/python-react-agent-simple-ui/server/claude_agent_with_spotter3_mcp_server.py create mode 100644 mcp/python-react-agent-simple-ui/server/claude_agent_with_spotter3_mcp_server_and_chat_history.py diff --git a/mcp/python-react-agent-simple-ui/README.md b/mcp/python-react-agent-simple-ui/README.md index 1e4adab..8b8312f 100644 --- a/mcp/python-react-agent-simple-ui/README.md +++ b/mcp/python-react-agent-simple-ui/README.md @@ -1,6 +1,6 @@ # Python Agent with Simple React UI -A full-stack example that pairs a **Python (FastAPI) agent** with a **React chat UI**. Supports two backend implementations: +A full-stack example that pairs a **Python (FastAPI) agent** with a **React chat UI**, running against the ThoughtSpot MCP server's **Spotter 3** toolset. Two backends are included: -| Backend | File | AI Provider | MCP Integration | -|-----------------|----------------------------------------|-----------------------|------------------------------------------------| -| **v1 (OpenAI)** | `server/agent.py` | Azure OpenAI / OpenAI | Server-side (OpenAI manages MCP) | -| **v2 (Claude)** | `server/claude_agent_mcp_server_v2.py` | Anthropic Claude | Client-side (FastAPI connects to MCP directly) | -| **v2 (OpenAI)** | _(coming soon)_ | Azure OpenAI / OpenAI | Client-side (FastAPI connects to MCP directly) | +| Backend | File | What it gives you | +|-------------------------|--------------------------------------------------------------------|------------------------------------------------------------| +| **Spotter 3** | `server/claude_agent_with_spotter3_mcp_server.py` | The agent, with history held in memory for the process life | +| **Spotter 3 + history** | `server/claude_agent_with_spotter3_mcp_server_and_chat_history.py` | The same, plus SQLite history you can list, reopen and delete | - +Both use Anthropic Claude with a client-side MCP loop — the FastAPI process connects to the MCP server directly. The backend streams responses to the frontend using Server-Sent Events (SSE), giving users a real-time chat experience while the agent queries ThoughtSpot for data insights and displays ThoughtSpot charts in an embed. @@ -32,16 +32,16 @@ The backend streams responses to the frontend using Server-Sent Events (SSE), gi --- -## MCP Server v2: Claude + Client-side MCP (Recommended) +## Claude + the Spotter 3 MCP Server -`claude_agent_mcp_server_v2.py` uses Anthropic's Claude API with a **client-side agentic loop** — the FastAPI process connects directly to the ThoughtSpot MCP server using custom HTTP headers (`Authorization` + `x-ts-host`). This approach is required because Anthropic's server-side MCP integration does not support custom headers. +`claude_agent_with_spotter3_mcp_server.py` uses Anthropic's Claude API with a **client-side agentic loop** — the FastAPI process connects directly to the [ThoughtSpot MCP server](https://github.com/thoughtspot/mcp-server) using custom HTTP headers (`Authorization` + `x-ts-host`). This is required because Anthropic's server-side MCP connector cannot send custom headers. -### Architecture (v2) +### Architecture ``` ┌──────────────┐ SSE stream ┌──────────────────────┐ MCP (streamable-http) ┌─────────────┐ │ React Chat │ ◄────────────► │ FastAPI + Claude │ ◄──────────────────────────────► │ ThoughtSpot │ -│ (Vite) │ /api/chat │ / OpenAI │ Authorization + x-ts-host │ MCP Server │ +│ (Vite) │ /api/chat │ │ Authorization + x-ts-host │ MCP Server │ └──────────────┘ └──────────────────────┘ └─────────────┘ :5173 :8000 agent.thoughtspot.app ``` @@ -49,20 +49,20 @@ The backend streams responses to the frontend using Server-Sent Events (SSE), gi **Request flow:** 1. User sends a message from the React UI -2. FastAPI opens a new MCP session to `agent.thoughtspot.app` with auth headers +2. FastAPI opens an MCP session to `agent.thoughtspot.app` with auth headers and fetches the tool list 3. Claude receives the user message + ThoughtSpot tool definitions -4. Claude calls ThoughtSpot tools as needed; FastAPI executes each call via the MCP session -5. The agentic loop continues until Claude stops calling tools -6. Text deltas and status events are streamed to the UI over SSE in real time +4. Claude calls ThoughtSpot tools; FastAPI executes each call over the MCP session +5. For `get_session_updates`, FastAPI polls the Analytics Agent to completion itself (see below) +6. Text deltas, agent progress, and rendered answers stream to the UI over SSE in real time -### Prerequisites (v2) +### Prerequisites -- Python 3.10+ +- Python 3.10+ (required by `anthropic` 1.x) - Node.js 18+ - Anthropic API key -- ThoughtSpot instance with a host URL and Authentication token(bearer token) +- ThoughtSpot instance with a host URL and an authentication (bearer) token -### Environment Setup (v2) +### Environment Setup From the project root (`python-react-agent-simple-ui/`): @@ -70,7 +70,7 @@ From the project root (`python-react-agent-simple-ui/`): cp env.template .env ``` -Edit `.env` — v2 uses these variables: +Edit `.env` — the agent uses these variables: ```env # Server-side — used by the Claude agent @@ -79,13 +79,18 @@ ANTHROPIC_API_KEY=your_anthropic_api_key_here # ThoughtSpot credentials (VITE_ prefix makes them available to the React client too) VITE_TS_HOST=your-instance.thoughtspot.cloud VITE_TS_AUTH_TOKEN=your_thoughtspot_bearer_token + +# Optional overrides +# ANTHROPIC_MODEL=claude-opus-5 +# TS_MCP_API_VERSION=2026-05-01 +# TS_MCP_URL=https://agent.thoughtspot.app/token/mcp?api-version=2026-05-01 ``` > **Note:** `VITE_TS_HOST` / `VITE_TS_AUTH_TOKEN` are read by both the Python server and the React client. You can also set them without the `VITE_` prefix as `TS_HOST` / `TS_AUTH_TOKEN` if you only need server-side access. > **Warning:** Using a static bearer token is for development and demo purposes only. For production, implement the [Trusted Authentication](https://developers.thoughtspot.com/docs/trusted-auth) flow where your backend generates short-lived tokens per user. -### Running v2 +### Running the agent **Backend:** @@ -94,7 +99,7 @@ cd server python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -r requirements.txt -uvicorn claude_agent_mcp_server_v2:app --reload +uvicorn claude_agent_with_spotter3_mcp_server:app --reload ``` **Frontend** (separate terminal): @@ -107,186 +112,219 @@ npm run dev Open `http://localhost:5173`. The Vite dev server proxies `/api` to the FastAPI backend on port 8000. -### How v2 Works +Sanity check the MCP connection without the model: + +```bash +curl -s http://localhost:8000/api/tools | python -m json.tool +``` -#### Conversation management +### How it Works -v2 maintains full conversation history (including all tool interactions) in an in-memory dict keyed by `conv_id`. Each `/api/chat` request either starts a new conversation or continues an existing one by passing the `response_id` returned in the previous `done` event. +#### MCP endpoint and API version ``` -conversations: { conv_id → [user msg, assistant msg + tool calls, tool results, ...] } +https://agent.thoughtspot.app/token/mcp?api-version=latest +``` + +The MCP server exposes several endpoint families and versions its toolset: + +| Path | Auth | Toolset | +|------|------|---------| +| `/token/mcp` | Static bearer token + `x-ts-host` | Version-negotiated via `api-version` | +| `/bearer/mcp` | Static bearer token (legacy) | Frozen on the older, pre-Spotter 3 toolset (`getAnswer`, `getRelevantQuestions`, …) | +| `/mcp` | OAuth | Version-negotiated | + +`api-version` accepts `latest`, `beta`, or a release date. This example defaults to `latest`, which always gets the newest toolset — convenient, but it means a ThoughtSpot release can change the tools and the update shape underneath you. **For anything you depend on, pin a release date** via `TS_MCP_API_VERSION`: a pinned date does not move, so your prompt and your tool-handling code stay in sync. `2026-05-01` is the first release of the Spotter 3 (analytical session) toolset. + +`list_orgs` / `switch_org` are OAuth-only — the server hides them on `/token/*`, so they never appear in the tool list for this static-token setup. + +#### Server-side polling of `get_session_updates` + +The ThoughtSpot Analytics Agent answers asynchronously: `send_session_message` returns immediately, and `get_session_updates` must be polled until `is_done: true`. Letting the *model* poll costs a full Claude round-trip per poll, and the first few polls usually return nothing at all. + +Instead `autopoll_session_updates()` does it in-process: it polls with backoff until the Agent is done, accumulates every update, and hands Claude **one** consolidated tool result. Progress (`step_notification` and thinking text) streams to the UI as `status` events while it waits. + +```python +POLL_INITIAL_DELAY = 0.75 # seconds before the first re-poll +POLL_MAX_DELAY = 4.0 # backoff cap; resets whenever new updates arrive +POLL_TIMEOUT = 300.0 # give up and tell the model what did arrive ``` -#### Analytical session continuity +#### Answers are rendered by the client, not the model -When the Claude model calls `create_analysis_session`, the server stores the returned `analytical_session_id` and injects it into the system prompt for all follow-up turns. This lets `send_session_message` / `get_session_updates` calls reference the same ThoughtSpot analytical session across multiple questions. +Each `answer` update carries an `iframe_url` with a `tsmcp=true` marker. The server streams it to the browser as an `answer` SSE event; `App.jsx` mounts a bare ``; +}; + function App() { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const [status, setStatus] = useState(""); const [responseId, setResponseId] = useState(null); + // ThoughtSpot analytical session for the open conversation. Only a reopened + // chat needs it - live answers arrive with a URL already attached. + const [sessionId, setSessionId] = useState(null); + // Chat history. `historyAvailable` stays false against a backend without the + // /api/conversations endpoints, and the sidebar simply isn't rendered. + const [conversations, setConversations] = useState([]); + const [historyAvailable, setHistoryAvailable] = useState(false); const messagesEndRef = useRef(null); const textareaRef = useRef(null); + const refreshConversations = useCallback(async () => { + try { + const response = await fetch(HISTORY_URL); + if (!response.ok) throw new Error(String(response.status)); + const data = await response.json(); + setConversations(data.conversations || []); + setHistoryAvailable(true); + } catch { + setHistoryAvailable(false); + } + }, []); + + useEffect(() => { + refreshConversations(); + }, [refreshConversations]); + + // Reopen a stored conversation. Stored turns carry their answers, so the charts + // come back as embeds rather than as text - resolved live, since the stored + // answers deliberately carry no URL. See answerSrc. + const openConversation = useCallback( + async (id) => { + if (isLoading) return; + try { + const response = await fetch(`${HISTORY_URL}/${id}`); + if (!response.ok) throw new Error(String(response.status)); + const data = await response.json(); + setMessages( + (data.turns || []).map((turn) => ({ + role: turn.role, + content: turn.content, + answers: turn.answers || [], + })), + ); + setResponseId(data.id); + setSessionId(data.analytical_session_id || null); + setStatus(""); + } catch (error) { + setStatus(`Could not open that conversation: ${error.message}`); + } + }, + [isLoading], + ); + + const deleteConversation = useCallback( + async (id, event) => { + event.stopPropagation(); + await fetch(`${HISTORY_URL}/${id}`, { method: "DELETE" }); + if (id === responseId) { + setMessages([]); + setResponseId(null); + } + refreshConversations(); + }, + [responseId, refreshConversations], + ); + const scrollToBottom = useCallback(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, []); @@ -75,7 +285,7 @@ function App() { setMessages((prev) => [ ...prev, { role: "user", content: userMessage }, - { role: "assistant", content: "" }, + { role: "assistant", content: "", answers: [] }, ]); try { @@ -126,16 +336,40 @@ function App() { case "status": setStatus(data.message); break; + // An answer the Analytics Agent produced. The server streams these as + // soon as they arrive, so charts appear while the agent is still working. + case "answer": { + // Raw MCP updates have no `iframe_url`, so an answer is renderable + // as long as a src can be built from what it does carry. + const src = answerSrc(data, null); + if (!src) break; + setMessages((prev) => { + const updated = [...prev]; + const last = updated[updated.length - 1]; + const answers = last.answers || []; + if (answers.some((a) => answerSrc(a, null) === src)) return prev; + updated[updated.length - 1] = { + ...last, + answers: [...answers, data], + }; + return updated; + }); + break; + } case "done": setResponseId(data.response_id); setStatus(""); + // The server has written the turn by the time it sends `done`. + refreshConversations(); break; case "error": setMessages((prev) => { const updated = [...prev]; + const last = updated[updated.length - 1]; updated[updated.length - 1] = { role: "assistant", content: `Error: ${data.message}`, + answers: last.answers || [], isError: true, }; return updated; @@ -175,99 +409,166 @@ function App() { const startNewChat = () => { setMessages([]); setResponseId(null); + setSessionId(null); setStatus(""); setInput(""); }; return ( -
-
-
-

ThoughtSpot Agent

- {messages.length > 0 && ( - - )} -
-
- -
- {messages.length === 0 ? ( -
-
TS
-

Ask anything about your data

-

- Powered by ThoughtSpot and OpenAI. Ask questions and get insights - from your connected data sources. -

+
+ {historyAvailable && ( + + )} + +
+
+
+

ThoughtSpot Agent

+ {messages.length > 0 && ( + + )}
- ) : ( -
- {messages.map((msg, i) => ( -
-
- {msg.role === "user" ? "U" : "TS"} +
+ +
+ {messages.length === 0 ? ( +
+
TS
+

Ask anything about your data

+

+ Powered by ThoughtSpot and Claude. Ask questions and get + insights from your connected data sources. +

+
+ ) : ( +
+ {messages.map((msg, i) => ( +
+
+ {msg.role === "user" ? "U" : "TS"} +
+
+ {msg.role !== "assistant" ? ( +

{msg.content}

+ ) : ( + <> + {(msg.answers || []).map((answer) => ( +
+ {answer.title && ( +
{answer.title}
+ )} +
+
+ ))} + {msg.content ? ( + // rehypeRaw so an " # important! - "Do not ask to create charts, as thoughtspot will already create interactive charts for you." - "Respond in an engaging markdown format, with html tags when needed." - "Keep the response short and to the point." - # "Use this datasource: cd252e5c-b552-49a8-821d-3eadaa049cca to answer all data questions." -) - -# ThoughtSpot MCP Tools (v2): -# To restrict which tools are accessible to the agent, set ALLOWED_TOOLS to a list of tool names. -# Set to None to allow all tools. -# -# The v2 MCP server uses an analytical session workflow: -# 1. check_connectivity - Test connectivity and authentication. No inputs. -# 2. create_analysis_session - Start a session. Optional: data_source_id. -# Returns: analytical_session_id. -# 3. send_session_message - Send a natural-language question to the session. -# Inputs: analytical_session_id, message, additional_context (optional). -# 4. get_session_updates - Poll for incremental updates. Inputs: analytical_session_id. -# Returns: session_updates (list), is_done (bool). -# Poll until is_done=True. Each update has type: text | text_chunk | answer. -# Answer updates include: answer_id, answer_title, answer_query, iframe_url. -# 5. create_dashboard - Create a dashboard from answer IDs. -# Inputs: title, answers (list of answer_ids), note_tile. -# Returns: link. -ALLOWED_TOOLS = None -# ALLOWED_TOOLS = ["check_connectivity", "create_analysis_session", "send_session_message", "get_session_updates", "create_dashboard"] - -# In-memory conversation store: conv_id -> full message history (including tool interactions) -conversations: dict[str, list] = {} - -# 1:1 mapping: conv_id -> analytical_session_id returned by create_analysis_session tool. -# Passed to Claude via system prompt so follow-up send_session_message / get_session_updates -# calls use the same ThoughtSpot analytical session. -analytical_sessions: dict[str, str] = {} - - -class ChatRequest(BaseModel): - message: str - response_id: str | None = None - - -def format_sse(data: dict) -> str: - return f"data: {json.dumps(data)}\n\n" - - -async def agent_loop(messages: list, queue: asyncio.Queue, conv_id: str) -> None: - """ - Client-side agentic loop. Connects to the ThoughtSpot MCP server directly - (with Authorization + x-ts-host headers), fetches tool definitions, then - runs the Claude tool-use loop until the model stops calling tools. - Puts SSE event dicts into queue for streaming to the frontend. - """ - try: - headers = dict(MCP_HEADERS) - - print(f"[MCP] Connecting to {MCP_URL}") - async with streamablehttp_client(MCP_URL, headers=headers) as (read, write, _): - async with ClientSession(read, write) as session: - print("[MCP] Initializing session...") - await session.initialize() - print("[MCP] Session initialized. Fetching tools...") - - # Fetch tool definitions from ThoughtSpot MCP server - tools_result = await session.list_tools() - print(f"[MCP] Got {len(tools_result.tools)} tools") - available_tools = tools_result.tools - # Optionally filter tools based on ALLOWED_TOOLS - if ALLOWED_TOOLS is not None: - available_tools = [t for t in available_tools if t.name in ALLOWED_TOOLS] - - # Convert MCP tool definitions to Anthropic format - anthropic_tools = [ - { - "name": t.name, - "description": t.description or "", - "input_schema": t.inputSchema, - } - for t in available_tools - ] - - current_messages = messages[:] - final_text_parts: list[str] = [] - - # Build system prompt, injecting analytical_session_id for follow-up turns - system = SYSTEM_PROMPT - existing_session_id = analytical_sessions.get(conv_id) - if existing_session_id: - system += ( - f"\n\nActive ThoughtSpot analytical session ID: {existing_session_id}. " - "Use this ID when calling send_session_message or get_session_updates " - "so follow-up questions continue in the same session." - ) - - while True: - async with claude_client.messages.stream( - model="claude-opus-4-6", - max_tokens=16000, - system=system, - messages=current_messages, - tools=anthropic_tools, - ) as stream: - async for event in stream: - t = getattr(event, "type", None) - if t == "content_block_start": - if getattr(event.content_block, "type", None) == "tool_use": - await queue.put({"type": "status", "message": "Querying ThoughtSpot..."}) - elif t == "content_block_delta": - delta = event.delta - if getattr(delta, "type", None) == "text_delta": - await queue.put({"type": "delta", "text": delta.text}) - final_text_parts.append(delta.text) - - final_message = await stream.get_final_message() - - if final_message.stop_reason != "tool_use": - break - - # Execute each tool call via MCP client (headers are set on the session) - tool_results = [] - for block in final_message.content: - if getattr(block, "type", None) == "tool_use": - try: - mcp_result = await session.call_tool(block.name, block.input) - print(f"[MCP] Tool {block.name} and input {block.input} returned: {mcp_result}") - result_text = " ".join( - getattr(c, "text", str(c)) for c in mcp_result.content - ) if mcp_result.content else "" - is_error = getattr(mcp_result, "isError", False) - - # Store analytical_session_id (1:1 with conv_id) so follow-up - # requests can reference the same ThoughtSpot session. - if block.name == "create_analysis_session" and not analytical_sessions.get(conv_id): - try: - sid = json.loads(result_text).get("analytical_session_id") - if sid: - analytical_sessions[conv_id] = sid - print(f"[MCP] Stored analytical_session_id for conv {conv_id}: {sid}") - except Exception: - pass - - except McpError as e: - print(f"[MCP] Tool {block.name} failed: {e}") - result_text = f"Tool call failed: {e}" - is_error = True - tool_results.append({ - "type": "tool_result", - "tool_use_id": block.id, - "content": result_text, - "is_error": is_error, - }) - - # Append assistant turn + tool results and continue the loop - current_messages = current_messages + [ - {"role": "assistant", "content": final_message.content}, - {"role": "user", "content": tool_results}, - ] - final_text_parts = [] # reset; next iteration may stream more text - - # Persist full conversation history (including tool interactions) so - # follow-up turns have complete context (e.g. analytical_session_id in prior results). - conversations[conv_id] = current_messages + [ - {"role": "assistant", "content": final_message.content} - ] - await queue.put({"type": "done", "response_id": conv_id}) - - except BaseException as e: - traceback.print_exc() - # Recursively unwrap ExceptionGroup to get the root cause - err = e - while hasattr(err, "exceptions") and getattr(err, "exceptions", None): - err = err.exceptions[0] - await queue.put({"type": "error", "message": f"{type(err).__name__}: {err}"}) - - -@app.post("/api/chat") -async def chat(request: ChatRequest): - conv_id = request.response_id or str(uuid.uuid4()) - print(f"[Chat] Received message for conv_id {conv_id}: {request.response_id}") - history = conversations.get(conv_id, []) - messages = history + [{"role": "user", "content": request.message}] - - queue: asyncio.Queue = asyncio.Queue() - asyncio.create_task(agent_loop(messages, queue, conv_id)) - - async def event_stream() -> AsyncGenerator[str, None]: - while True: - item = await queue.get() - yield format_sse(item) - if item.get("type") in ("done", "error"): - break - - return StreamingResponse(event_stream(), media_type="text/event-stream") - - -@app.get("/api/health") -async def health(): - return {"status": "ok"} diff --git a/mcp/python-react-agent-simple-ui/server/claude_agent_with_spotter3_mcp_server.py b/mcp/python-react-agent-simple-ui/server/claude_agent_with_spotter3_mcp_server.py new file mode 100644 index 0000000..4f6f748 --- /dev/null +++ b/mcp/python-react-agent-simple-ui/server/claude_agent_with_spotter3_mcp_server.py @@ -0,0 +1,675 @@ +""" +Python agent: Anthropic Claude + the ThoughtSpot MCP server (Spotter 3 toolset). + +FastAPI streams chat responses to the React frontend over Server-Sent Events. + +Why client-side MCP: the ThoughtSpot MCP server's static-token endpoint needs custom +HTTP headers (Authorization + x-ts-host). Anthropic's server-side MCP connector cannot +send those, so this process connects to the MCP server itself and executes tool calls. + +Two things this server does that a plain pass-through loop does not: + +1. `get_session_updates` polling happens here, not in the model. The ThoughtSpot + Analytics Agent answers asynchronously, so one `get_session_updates` call usually + returns `is_done: false` and an empty list. Letting the model poll costs a full + model round-trip per poll. Instead we poll until `is_done: true` and hand the model + one consolidated result, streaming the Agent's progress to the UI as it arrives. + +2. Answers are rendered by the client, not by the model. Each `answer` update becomes + an iframe marked `tsmcp=true`, which the React client mounts and the Visual Embed + SDK's `startAutoMCPFrameRenderer` upgrades into a real ThoughtSpot embed. We keep + the URL out of what the model sees - it is long, and the model does not need to + echo markup for a chart the UI has already drawn. + +MCP server: https://github.com/thoughtspot/mcp-server +""" + +import asyncio +import json +import os +import time +import traceback +import uuid +from collections.abc import AsyncGenerator +from pathlib import Path +from typing import Any + +import anthropic +import httpx2 +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from mcp import Client, MCPError +from mcp.client.streamable_http import streamable_http_client +from pydantic import BaseModel + +load_dotenv(dotenv_path=Path(__file__).resolve().parent.parent / ".env") + +app = FastAPI(title="ThoughtSpot Agent") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# ── Anthropic ─────────────────────────────────────────────────────────────────── +claude_client = anthropic.AsyncAnthropic(api_key=os.getenv("ANTHROPIC_API_KEY")) + +MODEL = os.getenv("ANTHROPIC_MODEL", "claude-opus-5") +MAX_TOKENS = 16000 + +# Safety classifiers can decline a request (HTTP 200, stop_reason="refusal"). The +# server-side fallback beta re-routes those to a fallback model automatically. +REFUSAL_FALLBACK_BETA = "server-side-fallback-2026-07-01" + +# ── ThoughtSpot MCP server ────────────────────────────────────────────────────── +TS_HOST = os.getenv("VITE_TS_HOST") or os.getenv("TS_HOST") +TS_AUTH_TOKEN = os.getenv("VITE_TS_AUTH_TOKEN") or os.getenv("TS_AUTH_TOKEN") + +if not TS_AUTH_TOKEN or not TS_HOST: + raise RuntimeError( + "TS_AUTH_TOKEN and TS_HOST (or their VITE_ prefixed versions) must be set in .env" + ) + +# The `/token/*` endpoint family is the static-bearer-token transport. +# (`/bearer/*` is the legacy path and is frozen on the older v1 toolset.) +# +# `latest` tracks the newest toolset, so a ThoughtSpot release can change the tools +# and the update shape under this app. Set TS_MCP_API_VERSION to a release date +# (e.g. 2026-05-01) to pin it instead. +MCP_API_VERSION = os.getenv("TS_MCP_API_VERSION", "latest") +# +# Adding `&enable-raw-session-updates=true` makes the server stream the Agent's own +# updates instead of its digested ones. This app reads either - see "Reading session +# updates" below - so you can turn it on through TS_MCP_URL without touching the code. +MCP_URL = os.getenv( + "TS_MCP_URL", + f"https://agent.thoughtspot.app/token/mcp?api-version={MCP_API_VERSION}", +) + +MCP_HEADERS = { + "Authorization": f"Bearer {TS_AUTH_TOKEN}", + "x-ts-host": TS_HOST, +} + +# ── Embed token minting ───────────────────────────────────────────────────────── +# The Visual Embed SDK requires a FRESH token from every `getAuthToken` call. Handing +# it the same static token twice trips its duplicate-token check the moment the token +# stops verifying, and the callback can never recover because it returns the same +# string. So the browser asks this server for a token instead, and this server mints a +# short-lived one per request. +# +# Minting needs a username plus either the cluster secret key (Develop > Customizations +# > Security Settings > Trusted authentication) or that user's password. `secret_key` +# takes precedence when both are set. +TS_EMBED_USERNAME = os.getenv("TS_EMBED_USERNAME") +TS_SECRET_KEY = os.getenv("TS_SECRET_KEY") +TS_EMBED_PASSWORD = os.getenv("TS_EMBED_PASSWORD") +TS_TOKEN_VALIDITY_SEC = int(os.getenv("TS_TOKEN_VALIDITY_SEC", "1800")) + +CAN_MINT_TOKENS = bool(TS_EMBED_USERNAME and (TS_SECRET_KEY or TS_EMBED_PASSWORD)) + +if not CAN_MINT_TOKENS: + print( + "[Auth] TS_EMBED_USERNAME + TS_SECRET_KEY (or TS_EMBED_PASSWORD) are not set - " + "/api/ts-token will serve the static TS_AUTH_TOKEN. Fine for a local demo, but " + "the SDK cannot recover once that token expires." + ) + +# Long read timeout: MCP replies stream over SSE and the Analytics Agent is slow. +MCP_TIMEOUT = httpx2.Timeout(30.0, read=300.0) + +# ThoughtSpot MCP tools, as exposed by the Spotter 3 toolset: +# +# check_connectivity - test connectivity + auth. No inputs. +# search_objects - find existing Liveboards / Answers / Worksheets by name. +# Metadata only; never returns data. +# create_analysis_session - start a session. Optional: data_source_id. +# Returns analytical_session_id. +# send_session_message - ask the Analytics Agent a question. +# Inputs: analytical_session_id, message, additional_context. +# get_session_updates - poll for updates. Input: analytical_session_id. +# Returns session_updates[] + is_done. See AUTOPOLL below. +# create_dashboard - build a dashboard from answer_ids. +# Inputs: title, answers[], note_tile. Returns link. +# list_orgs / switch_org - OAuth-only. The server hides them on `/token/*`, so they +# never appear in the tool list for this static-token setup. +# +# Set ALLOWED_TOOLS to a list of names to restrict the agent. None allows everything +# the server exposes, which is the right default: the tool list is version-negotiated, +# so a hardcoded list silently drops tools added in later API versions. +ALLOWED_TOOLS: list[str] | None = None + +# ── Server-side polling of get_session_updates ────────────────────────────────── +POLL_TOOL = "get_session_updates" +POLL_INITIAL_DELAY = 0.75 # seconds before the first re-poll +POLL_MAX_DELAY = 4.0 # cap on the backoff +POLL_TIMEOUT = 300.0 # give up after this long without is_done + +SYSTEM_PROMPT = """You are a data analyst assistant powered by ThoughtSpot's Analytics Agent. + +Workflow: +- Create one analysis session per conversation with `create_analysis_session`, then ask + questions with `send_session_message`, then call `get_session_updates` once. +- `get_session_updates` is polled to completion for you: a single call returns the Agent's + full response, so never call it twice for the same question. +- Use `search_objects` to find existing Liveboards, Answers or Worksheets by name. It + returns metadata only, never data - to answer a data question, ask the Agent. +- Use `create_dashboard` when the user wants to save or share results, passing the + `answer_id` values from the answers you want on it. + +Presenting answers: +- Every `answer` update is ALREADY rendered in the UI as an interactive ThoughtSpot chart, + in the order it was returned. Do not emit