Skip to content

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.)

Terminal window
git clone https://github.com/velerion/example-mcp-analytics
cd example-mcp-analytics
npm install
npm run warehouse:build # builds ./data/warehouse.duckdb from CSV fixtures
velerion env use dev
velerion connection create warehouse-ro --type duckdb --env dev --secret DUCKDB_PATH
velerion mcp install velerion/duckdb@1.3.0 --env dev --connection warehouse-ro
npm run dev # http://localhost:3000
  • 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.
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.

app/api/ask/route.ts
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.

  • 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 a LIMIT rewrite before pointing this at anything real.
  • The fixture warehouse is roughly 40 MB, small enough that latency here is not representative.