Snowflake’s Managed MCP Server: Building a Chat UI (Part 2)
有效阅读仅保存在当前浏览器,用于「为你」推荐;暂停或清除记录。
DataHot 速览
The finished console: tools on the left, a chat with the Cortex Agent on the right, all running locally against Snowflake’s managed MCP server. This is Part 2 of a two-part series. In Part 1: Snowflake’s Managed MCP Server: Giving AI Assistants a Governed Door Into Your Warehouse , I built two MCP s
本文目录 12 节
- Why build a UI at all?
- The architecture: a tiny proxy in the middle
- Letting the tools describe the UI
- What’s actually inside an agent response
- Charts: a pie and a bar
- Making follow-up questions work
- Things I learned building it
- Are the answers actually right?
- What this is and isn’t
- Running it
- Wrapping up
- References
原文
This is Part 2 of a two-part series. In Part 1: Snowflake’s Managed MCP Server: Giving AI Assistants a Governed Door Into Your Warehouse, I built two MCP servers in Snowflake (a Cortex Agent for the ops team and a read-only SQL server for engineers) and tested them with curl. This post picks up right where that one ended: I build a small web UI that talks to those same servers. If you haven’t read Part 1, start there. The objects, roles and tools below all come from it.
Why build a UI at all?
At the end of Part 1 I had two working MCP servers and a pile of curl commands. That’s fine for me. It’s not fine for the ops lead who started this whole thing by asking “Are the dashboards up to date?” every Monday.
The obvious answer is “connect Claude or Cursor”, and that’s still the long-term plan. But wiring up a real client means an OAuth security integration, and in our shared account that’s an admin conversation, not something I create on a quiet afternoon. I also wanted something Part 1 didn’t give me: a way to see what the agent does. Which tool it called, what SQL it generated, how long it took, how many tokens it burned.
So I built a small console. It turned out to be one of the more useful debugging tools I’ve made this year.
The architecture: a tiny proxy in the middle
Browser (index.html) ──► localhost:8766 (app.py) ──► Snowflake MCP endpoint
UI only PAT, validation, SSE your RBAC appliesI didn’t want the browser calling Snowflake directly, for three reasons:
- The token. A programmatic access token (PAT) in browser JavaScript is a token in your browser history, extensions and devtools. The proxy reads it from a file at request time and never sends it to the page.
- Server-Sent Events. Since August 20, 2026, tools/call responses stream as SSE. Parsing that once in Python is easier than in every UI.
- A choke point. The proxy is the one place where I decide what’s allowed.
The heart of it is one function. It builds the request exactly like the curl commands in Part 1:
url = f"{BASE_URL}/databases/{database}/schemas/{schema}/mcp-servers/{server}"
body = json.dumps({"jsonrpc": "2.0", "id": int(time.time() * 1000), "method": method,
"params": params or {}}).encode()
headers = {
"Authorization": f"Bearer {read_token()}",
"X-Snowflake-Authorization-Token-Type": "PROGRAMMATIC_ACCESS_TOKEN",
"Content-Type": "application/json",
# Since 2026-08-20 tools/call streams as SSE; both types are required.
"Accept": "application/json, text/event-stream",
}
if role:
# Not in the MCP docs, but honored in testing. A role-restricted PAT is the documented way.
headers["X-Snowflake-Role"] = roleAnd the SSE parsing. It ignores the : keep-alive lines an agent sends while it's thinking, skips a [DONE] event if one ever shows up (in Part 1 I never saw one, even though the docs mention it), and keeps the last JSON-RPC message:
def parse_body(raw, content_type):
"""Return the JSON-RPC message from a plain JSON or SSE (text/event-stream) body."""
if "text/event-stream" not in content_type:
return json.loads(raw)
message = None
for line in raw.splitlines():
if not line.startswith("data:"):
continue # ': keep-alive' comments and 'event:' lines
payload = line[5:].strip()
if not payload or payload == "[DONE]":
continue
try:
obj = json.loads(payload)
except ValueError:
continue
if isinstance(obj, dict) and "jsonrpc" in obj:
message = obj
if message is None:
raise ValueError("No JSON-RPC message found in the event stream")
return messageThe guard rails are boring on purpose:
IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]{0,254}$")
ALLOWED_METHODS = {"tools/list", "tools/call"}Database, schema, server and role names all have to match IDENT, so nobody can sneak ../ into the URL path. The proxy binds to 127.0.0.1 only and rejects any request whose Origin header isn't the console itself. That way a random website you have open can't use your local proxy to call Snowflake on your behalf.
Letting the tools describe the UI
This is the part I like most. I didn’t hard-code a single tool. The page calls tools/list and builds itself from the answer.
The rule for “is this an agent?” is one line. Agent tools are the only ones whose schema has both a text and a thread_id property:
function isAgentTool(t) { const p = t.inputSchema?.properties || {};
return "text" in p && "thread_id" in p; }Agents open as a chat. Everything else (my pipeline_freshness procedure, the read-only SQL tool, and any Analyst or Search tool you add later) gets a form generated from its inputSchema. Numbers become number inputs, booleans become checkboxes, and anything called sql or query becomes a textarea. The required list drives validation. Remember from Part 1 that required isn't in Snowflake's documented GENERIC example, but it does flow through to tools/list. This is exactly where it pays off.
Results get the same treatment. If a tool returns a JSON array of objects, like my freshness procedure, it becomes a table. If it returns a Snowflake result_set, like the read-only SQL tool, it becomes a table using the column names from rowType. Anything else is pretty-printed JSON. There's always a "Raw response" toggle, because when something looks wrong, the raw payload is the first thing I want.
What’s actually inside an agent response
In Part 1, I mostly looked at the final text. Building the UI forced me to read the whole thing, and there’s a lot more in there.
The MCP response wraps the agent’s answer as a string inside result.content[0].text. Parse that string and you get a full Cortex Agent message with a list of typed content items:
- text: Render as light Markdown; collect cortex_search_citation annotations as sources
- table: A result_set with column metadata, rendered as a table with a title
- chart: A Vega-Lite spec, rendered with vega-embed
- suggested_queries: Clickable chips that send the follow-up question
- tool_use / tool_result: Collapsed "Agent steps", including the SQL Cortex Analyst generated
Plus metadata: the thread_id, the assistant_message_id and token usage per model.
The rendering is a single loop over those types:
for (const c of agent.content || []) {
if (c.type === "text") {
parts.push(md(c.text));
for (const a of c.annotations || []) if (a.type === "cortex_search_citation") cites.push(a);
} else if (c.type === "table") {
parts.push(resultSetHtml(c.table.result_set || {}, c.table.title));
} else if (c.type === "chart") {
parts.push(`<div class="chart" data-spec="${esc(c.chart.chart_spec)}"></div>`);
} else if (c.type === "suggested_queries") {
// ...clickable chips
}
// tool_use / tool_result -> collapsed "Agent steps" with the generated SQL
}Charts: a pie and a bar
Charts were a nice surprise. Ask for one and the agent hands you a complete Vega-Lite spec with the data inline, in the chart_spec field of a chart content item. I load vega, vega-lite and vega-embed lazily, from pinned versions, only when a chart actually shows up.
To see how far that goes, I asked for a pie chart: “What share of completed-order revenue did each region contribute in the last 90 days? Show it as a pie chart.”
Then, in the same thread, a follow-up: “Now show completed-order revenue by month for the last 6 months as a bar chart.”
Here’s roughly what the agent sent for the pie, trimmed:
{
"mark": { "type": "arc" },
"title": "Share of Completed-Order Revenue by Region (Last 90 Days)",
"encoding": {
"theta": { "field": "COMPLETED_REVENUE", "type": "quantitative", "stack": true },
"color": { "field": "REGION", "type": "nominal" },
"tooltip": [ ... ]
},
"data": { "values": [ { "REGION": "APAC", "COMPLETED_REVENUE": 116989.08 }, ... ] }
}The catch: the first time a pie chart came back, my UI showed a big empty box. The spec was fine; my sizing wasn’t. I had passed width: "container", but vega-embed wraps the chart in an inline-block element, so the "container" was zero pixels wide and the chart rendered at zero width. My earlier test had only checked that an SVG existed, not that it had a size. The fix is to size charts from the chat bubble, and to treat pies differently so long titles and the legend still fit:
const avail = Math.max(280, (el.parentElement?.clientWidth || 640) - 40);
const mark = typeof spec.mark === "string" ? spec.mark : spec.mark?.type;
const isArc = mark === "arc" || mark === "pie";
const size = isArc ? { width: avail, height: Math.min(avail, 320) } // full width, round pie
: { width: avail, height: spec.height || 300 };
await vegaEmbed(el, { ...spec, ...size, autosize: { type: "fit", contains: "padding" }, background: "transparent" },
{ actions: false, renderer: "svg" });My first attempt at the fix made the pie a 360×360 square, which cut off the left edge of its title. Giving it the full bubble width with a capped height keeps it round and centered, with room for the title and legend.
Everything that came back from Snowflake goes through an HTML-escaping function before it touches the page, including the agent’s text. Model output is still untrusted input, even when the model is yours.
Making follow-up questions work
The agent tool’s input schema has two optional fields that are easy to miss: thread_id and parent_message_id. On the first message you send only text. The response metadata contains a thread_id and an assistant_message_id, and on the next turn you pass both back:
const args = { text };
if (thread[key]) { args.thread_id = thread[key].thread_id; args.parent_message_id = thread[key].parent; }
// ...after the response:
if (md_.thread_id) thread[key] = { thread_id: md_.thread_id, parent: md_.assistant_message_id };To test it, I asked “Which customer segment had the most returned orders in the last 90 days?” (SMB, 9 returns), then followed up with “Now break those returns down by region too, and show it as a bar chart.” The agent knew what “those returns” meant, and came back with a segment-by-region table and a chart. “New conversation” simply forgets the thread.
Things I learned building it
Almost everything is labelled “may write”. The UI shows a badge from each tool’s MCP annotations. The read-only SQL tool gets a green “read-only”. The agent and my freshness procedure get “may write”, because Snowflake marks CORTEX_AGENT_RUN and GENERIC tools as destructiveHint: true. Snowflake can't know my procedure only reads, so it plays safe. It's the right default, but expect real MCP clients to ask for approval on every call.
My agent advertised skills I never gave it. I asked the agent “what can I ask you?” (first screenshot). Next to the sales and support capabilities I configured, it listed two skills: migrations-redirect and skill-development, the second one for "Snowflake CoWork". My agent spec has no skills. I think this connects to something from Part 1: every tool call starts with a lookup of skills in the caller's personal workspace (snow://workspace/USER$/.../.snowflake/si/skills). I haven't confirmed where these two come from, but the lesson is the same either way. If you put an agent in front of business users, ask it what it can do before they do. Its own description of itself may include more than your spec.
You can see the cost. The UI shows time and tokens under every answer. “What can I ask you?” took 14 seconds and 39,755 tokens. A real data question with a table was around 13–17 seconds and ~100k tokens, and a chart follow-up was 26 seconds. None of this is hidden, it’s in the response metadata. Showing it in the UI changed how I write agent instructions.
Opaque errors deserve a translation layer. When the read-only SQL tool blocks a write, Snowflake returns MCP Server tool error: !399517!. That's it. The console now adds a one-line hint underneath ("this usually means the statement was blocked because the SQL tool is configured with read_only: true"). It's ten lines of code and it saves a confused message in Slack.
request-id only shows up in errors. Snowflake includes a request-id in the text of failed tool calls, and the console displays it. Successful calls didn't include one in my tests. For those, query history is the better trail: as I showed in Part 1, every query a tool runs is tagged cortex-agent-<request-id>.
My first version had a classic bug. I attached the “List tools” click handler after an async fetch of the default settings. Click fast enough and nothing happens. Nothing gets logged, nothing breaks, it just looks dead. Handlers now go on first, and the config loads afterwards.
Test what users see, not what the DOM contains. The blank pie chart passed my “is there an SVG?” check, and so did a bar chart before it. Both were zero pixels wide. Now my check is the size of the rendered chart, plus a screenshot.
Charts aren’t free either. The pie answer took 19.1 seconds and 189,482 tokens, and the bar chart follow-up 15.2 seconds and 167,871 tokens. A chart costs noticeably more than a plain table answer.
Are the answers actually right?
A nice UI makes wrong answers look just as convincing as right ones, so before publishing I recomputed every number in this series with my own SQL, straight against the tables. Most matched to the cent: revenue by region, the pie chart percentages, returns by segment and the monthly totals. Two didn’t, and neither was the model “hallucinating”. When I asked for “total revenue by region last quarter”, the agent summed all orders, including cancelled and returned ones, because that’s how I defined total_revenue in my semantic view. With completed orders only, EMEA drops from second place to last. And look closely at the bar chart above: every bar is labelled one month early. Snowflake returns dates like 2026-05-01. JavaScript reads that as midnight UTC, which is still April 30 here on the west coast, and Vega-Lite's yearmonth uses local time. The console now switches temporal fields to UTC time units (utcyearmonth) before rendering, and the screenshot above is from before that fix.
My takeaway: the agent is usually right about the SQL and wrong about what you meant. Open the “Agent steps” panel and read the WHERE clause. Keep a small set of questions with answers you’ve checked by hand, and re-run them whenever the agent or semantic view changes (Agent Studio’s Evaluations tab is built for this). Put the definitions people argue about, like which order statuses count as revenue, into the semantic view and the agent’s instructions, not into your users’ heads.
What this is and isn’t
It’s a local, single-user developer console. It is not a way to give a whole team access to Snowflake:
- It uses one identity. Everyone using it acts as whoever owns the PAT. For people, use OAuth so every user signs in as themselves (Part 1, Step 9).
- Use a role-restricted PAT. Part 1’s secondary-roles gotcha applies here too. A PAT without ROLE_RESTRICTION can quietly use every role the user has.
- X-Snowflake-Role is undocumented for MCP. It worked in my tests, but I wouldn't build anything important on it.
- No streaming to the browser. The proxy waits for the whole SSE response, then returns it. The UI shows an elapsed-time counter so a 25-second agent answer doesn’t feel frozen.
Running it
The whole thing is two files:
mcp_ui/
├── app.py # ~160 lines: stdlib proxy (http.server + urllib)
└── static/index.html # ~380 lines: HTML, CSS and vanilla JS# PAT in ~/.snowflake/pat_token (or set SNOWFLAKE_TOKEN_FILE)
python3 mcp_ui/app.py
# opens http://127.0.0.1:8766Change ACCOUNT and the DEFAULTS block in app.py to point at your own account, database, schema and servers. It only needs Python 3.9+ and a browser.
Wrapping up
Part 1 was about the server side: put a governed door in front of your warehouse. This part was about the other side of that door. My main takeaway is that the MCP contract is small enough that a useful client fits in an afternoon, and the Cortex Agent response is rich enough to deserve one.
If you build your own, two pieces of advice. Make the raw response one click away. And show time and tokens on every answer. You’ll design your agents differently once you see them.
References
- Part 1: Snowflake’s Managed MCP Server: Giving AI Assistants a Governed Door Into Your Warehouse
- Snowflake-managed MCP server
- MCP: Streaming responses for tool calls (August 2026)
- Programmatic access tokens
- Cortex Agents access control
- Model Context Protocol specification
- Vega-Lite
To stay updated on more Snowflake-related posts, follow me at my Medium profile: Eylon’s Snowflake Articles.
I’m Eylon Steiner, Principal Architect at Infostrux Solutions and a Snowflake Data Superhero. You can follow me on LinkedIn.
Subscribe to Infostrux Medium Blog at https://blog.infostrux.com for the most interesting Data Engineering and Snowflake news. Follow Infostrux’s open-source efforts through GitHub.
Snowflake’s Managed MCP Server: Building a Chat UI (Part 2) was originally published in Snowflake Builders Blog: Data Engineers, App Developers, AI, & Data Science on Medium, where people are continuing the conversation by highlighting and responding to this story.
这篇内容对你有用吗?
反馈只用于改善内容筛选,不等同于收藏