MCP-Powered Analytics
A chat interface that answers analytical questions over a DuckDB warehouse. Every query goes through a read-only MCP server, and the generated SQL is shown to the user alongside the answer.
Repository: github.com/velerion/example-mcp-analytics
(TODO(velerion): confirm the repository name before publishing.)
Run it
Section titled “Run it”git clone https://github.com/velerion/example-mcp-analyticscd example-mcp-analyticsnpm installnpm run warehouse:build # builds ./data/warehouse.duckdb from CSV fixtures
velerion env use devvelerion connection create warehouse-ro --type duckdb --env dev --secret DUCKDB_PATHvelerion mcp install velerion/duckdb@1.3.0 --env dev --connection warehouse-ro
npm run dev # http://localhost:3000What it demonstrates
Section titled “What it demonstrates”- Installing a catalog MCP server and allow-listing a single read-only tool.
- Passing the table schema in the system prompt so the model does not have to guess column names.
- Streaming tool calls and text to the browser through a server route.
- Showing the generated SQL in the UI, so a wrong answer is diagnosable.
Read-only by construction
Section titled “Read-only by construction”import { defineAgent, mcp } from '@velerion/sdk';import { warehouseSchema } from './lib/schema';
export default defineAgent({ name: 'analytics-assistant', model: 'claude-opus-5', instructions: ` Answer questions about the warehouse using the query tool. Schema: ${warehouseSchema}
Always show the SQL you ran. If a question cannot be answered from these tables, say so — do not invent a column. `, tools: [ mcp('velerion/duckdb', { connection: 'warehouse-ro', // `execute` exists in this server and is deliberately not allow-listed. tools: ['query'], }), ],});The connection itself is created against a read-only handle, so the allow-list is a second layer rather than the only one. Both matter: allow-lists are edited by developers, connection permissions by administrators.
Streaming route
Section titled “Streaming route”import { Velerion } from '@velerion/sdk';
const velerion = new Velerion({ environment: 'dev' });
export async function POST(request: Request) { const { question } = await request.json(); const stream = await velerion.agents.stream('analytics-assistant', { input: { question }, });
return new Response( new ReadableStream({ async start(controller) { const encode = (event: unknown) => controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)); try { for await (const event of stream) { // Tool calls are forwarded so the UI can render the SQL as it runs. if (event.type === 'text' || event.type === 'tool_call') encode(event); if (event.type === 'error') throw new Error(event.message); } } catch (error) { encode({ type: 'error', message: (error as Error).message }); } finally { controller.close(); } }, }), { headers: { 'content-type': 'text/event-stream' } }, );}The token is only ever used server-side. Never construct a Velerion client in browser code — the
credential would ship to the client.
Known limits of the example
Section titled “Known limits of the example”- Questions requiring more than three joins produce unreliable SQL. Add pre-built views for the queries you care about rather than trying to prompt around it.
- There is no query cost cap. A
SELECT *over a large table will happily run; add aLIMITrewrite before pointing this at anything real. - The fixture warehouse is roughly 40 MB, small enough that latency here is not representative.
