Skip to content
Docs

How to perform large-scale data analysis with Edgenivo Sandbox and MCP

Use Edgenivo Sandbox and an MCP server built with mcp-handler to let AI agents run DuckDB SQL and Python on large datasets without loading data into the model's context.

Ben SabicContent Engineer

Use Edgenivo Sandbox to run agent-generated SQL and Python against datasets with millions of rows. Expose the analysis tools through a Model Context Protocol (MCP) server deployed on Edgenivo, then connect clients such as Claude, Cursor, or your own AI SDK app.

The dataset loads into an isolated Linux microVM, where the agent's code runs. Returning schemas, aggregates, and capped results keeps large files out of the model's context window and avoids spending tokens on every row.

Copy link to headingPrerequisites

Before you begin, make sure you have:

  • A Edgenivo account.
  • An existing Next.js App Router project.
  • CSV or Parquet dataset reachable over HTTPS, such as a public Edgenivo Blob URL or a presigned Amazon S3 URL.
  • An MCP client such as Cursor or Claude Code, or use the MCP Inspector.

For local development, you also need Node.js 24+ and the Edgenivo CLI.

Plan limits decide how large a dataset one sandbox can handle and how long a single tool call can run:

PlanMax vCPUs per sandboxMax memory per sandboxDisk per sandboxMax sandbox sessionMax function duration
Hobby48 GB64 GB45 minutes300s
Pro816 GB64 GB24 hours800s, or 1800s with extended duration beta
Enterprise3264 GB64 GB24 hours800s, or 1800s with extended duration beta

Function limits assume Fluid compute. For this Next.js route, extended duration requires Node.js 22 or 24 and a maxDuration above 800 configured in the route. It is unavailable with Secure Compute or Static IPs during the beta.

Copy link to headingHow it works

Each tool call runs as a Edgenivo Function that asks Sandbox to execute a command and returns its output to the MCP client. The source file stays in the sandbox, where DuckDB loads it into a database for later queries. Reuse the same datasetId to avoid downloading it again.

This example gives one trusted owner access to every dataset and all three tools through a shared token. Run calls for each dataset sequentially. Shared services also need dataset authorization and coordination across function invocations.

ToolInputWhat runs in the sandboxWhat the model receives
load_datasetdatasetId, url, formatDownloads the file and loads it into the DuckDB table datasetSchema, row count, and file size
query_datasetdatasetId, sql, limit (optional)Read-only DuckDB SQL against datasetUp to 500 rows (100 by default)
run_pythondatasetId, codeA Python script with DuckDB, pandas, and PyArrow availableUp to 20,000 characters of stdout

Copy link to headingSteps

Copy link to heading1. Install dependencies

Install the Sandbox SDK, mcp-handler v2, the MCP server SDK v2, and Zod v4 in your Next.js project:

Terminal
pnpm i @vercel/sandbox mcp-handler@2.1.1 @modelcontextprotocol/server@2 zod@4
Terminal
npm i @vercel/sandbox mcp-handler@2.1.1 @modelcontextprotocol/server@2 zod@4
Terminal
yarn add @vercel/sandbox mcp-handler@2.1.1 @modelcontextprotocol/server@2 zod@4
Terminal
bun add @vercel/sandbox mcp-handler@2.1.1 @modelcontextprotocol/server@2 zod@4

Copy link to heading2. Authenticate with Edgenivo Sandbox

The Sandbox SDK authenticates with a Edgenivo OpenID Connect (OIDC) token tied to your project. Link the project and pull a development token for local work:

vercel link
vercel env pull

This writes VERCEL_OIDC_TOKEN to .env.local. In production on Edgenivo, the Sandbox SDK authenticates automatically without a separate Sandbox credential.

Copy link to heading3. Add the analysis scripts

The sandbox runs two Python scripts. load.py downloads the dataset to the sandbox's local disk and loads it into a DuckDB database file, and query.py runs read-only SQL against that file and caps the rows it returns. Store them as strings so your MCP server can write them into each new sandbox:

lib/analysis-scripts.ts
export const LOAD_SCRIPT = `
import json, os, urllib.request
import duckdb
url = os.environ["DATASET_URL"]
fmt = os.environ["DATASET_FORMAT"]
os.makedirs("data", exist_ok=True)
path = f"data/source.{fmt}"
urllib.request.urlretrieve(url, path)
con = duckdb.connect("data/analysis.duckdb")
reader = "read_parquet" if fmt == "parquet" else "read_csv_auto"
con.execute(f"CREATE OR REPLACE TABLE dataset AS SELECT * FROM {reader}('{path}')")
rows = con.execute("SELECT count(*) FROM dataset").fetchone()[0]
columns = con.execute("DESCRIBE dataset").fetchall()
print(json.dumps({
"table": "dataset",
"rows": rows,
"columns": [{"name": c[0], "type": c[1]} for c in columns],
"sourceBytes": os.path.getsize(path),
}))
`;
export const QUERY_SCRIPT = `
import json, os
import duckdb
os.makedirs("duckdb-tmp", exist_ok=True)
con = duckdb.connect(
"data/analysis.duckdb",
read_only=True,
config={"enable_external_access": False, "temp_directory": "duckdb-tmp"},
)
con.execute("SET lock_configuration = true")
limit = int(os.environ.get("ROW_LIMIT", "100"))
cursor = con.execute(os.environ["SQL"])
columns = [d[0] for d in cursor.description]
rows = cursor.fetchmany(limit + 1)
response = {
"columns": columns,
"rows": rows[:limit],
"truncated": len(rows) > limit,
}
encoded = json.dumps(response, default=str)
while len(encoded) > 20_000 and response["rows"]:
response["rows"].pop()
response["truncated"] = True
encoded = json.dumps(response, default=str)
if len(encoded) > 20_000:
encoded = json.dumps({"error": "Result schema is too large. Select fewer columns."})
print(encoded, end="")
`;

Copy link to heading4. Create a sandbox helper

Sandbox.getOrCreate() returns the named sandbox for a dataset ID or creates one if it doesn't exist. The onCreate callback installs DuckDB, pandas, PyArrow, and pytz in a virtual environment and writes the scripts from step 3.

Sandboxes are persistent by default, so the database survives between tool calls. The SDK resumes a stopped sandbox automatically when the next command runs.

lib/analysis-sandbox.ts
import { Sandbox } from '@vercel/sandbox';
import { LOAD_SCRIPT, QUERY_SCRIPT } from './analysis-scripts';
const MAX_OUTPUT_CHARS = 20_000;
export async function getAnalysisSandbox(datasetId: string) {
return Sandbox.getOrCreate({
name: `analysis-${datasetId}`,
image: 'vercel/sandbox/python:3.14',
resources: { vcpus: 8 }, // 16 GB of memory; use 4 on Hobby
timeout: 30 * 60 * 1000, // 30-minute session, resumes on the next call
keepLastSnapshots: { count: 1 },
snapshotExpiration: 7 * 24 * 60 * 60 * 1000, // 7 days
onCreate: async (sandbox) => {
try {
await sandbox.writeFiles([
{ path: 'load.py', content: Buffer.from(LOAD_SCRIPT) },
{ path: 'query.py', content: Buffer.from(QUERY_SCRIPT) },
]);
await run(sandbox, 'python3', ['-m', 'venv', '.venv']);
await run(sandbox, '.venv/bin/pip', ['install', 'duckdb', 'pandas', 'pyarrow', 'pytz'], undefined, false);
} finally {
await sandbox.update({ networkPolicy: 'deny-all' });
}
},
});
}
export async function run(
sandbox: Sandbox,
cmd: string,
args: string[],
env?: Record<string, string>,
captureOutput = true,
) {
const result = await sandbox.runCommand({ cmd, args, env });
if (result.exitCode !== 0) {
const stderr = await result.stderr();
throw new Error(`${cmd} exited with code ${result.exitCode}: ${stderr.slice(-2_000)}`);
}
if (!captureOutput) return '';
const stdout = await result.stdout();
if (stdout.length > MAX_OUTPUT_CHARS) {
throw new Error('Command output exceeds 20,000 characters. Return a smaller summary.');
}
return stdout;
}

query.py removes whole rows when needed to keep its JSON response within 20,000 characters. The run helper rejects larger output from other commands with a request for a smaller summary. These limits protect the model's context; they do not limit query memory, execution time, or the logs collected by the function.

DuckDB's Python client needs pytz to return TIMESTAMP WITH TIME ZONE columns, which are common in Parquet files, and pandas 3 no longer installs it. Install it during setup, because the sandbox has no network access after load_dataset runs.

Copy link to heading5. Expose the analysis tools from an MCP route

Create the route in app/api/mcp/route.ts. It registers the three analysis tools and requires a bearer token before any tool can run. Set MCP_SERVER_TOKEN in .env.local to a long, randomly generated secret that you keep out of version control.

load_dataset temporarily allows HTTPS traffic to the dataset host and restores deny-all after the attempt. Finish loading before starting another command in that dataset's sandbox. The policy applies to every command running there.

app/api/mcp/route.ts
import { randomUUID, timingSafeEqual } from 'node:crypto';
import type { AuthInfo } from '@modelcontextprotocol/server';
import { createMcpHandler, withMcpAuth } from 'mcp-handler';
import { z } from 'zod';
import { getAnalysisSandbox, run } from '@/lib/analysis-sandbox';
export const maxDuration = 800; // Standard Pro/Enterprise limit with Fluid compute; Hobby is 300
const DatasetId = z
.string()
.regex(/^[a-z0-9-]{1,40}$/)
.describe('Short lowercase ID for the dataset, such as "orders-2026"');
function text(value: string, isError = false) {
return { content: [{ type: 'text' as const, text: value }], isError };
}
const handler = createMcpHandler((server) => {
server.registerTool(
'load_dataset',
{
description:
'Download a CSV or Parquet file into an isolated sandbox and load it into the DuckDB table `dataset`. Returns the schema and row count. Load before querying, then reuse the dataset ID. Run calls for each dataset sequentially.',
inputSchema: z.object({
datasetId: DatasetId,
url: z.url().refine((value) => new URL(value).protocol === 'https:', 'Use an HTTPS dataset URL.'),
format: z.enum(['csv', 'parquet']),
}),
},
async ({ datasetId, url, format }) => {
try {
const sandbox = await getAnalysisSandbox(datasetId);
try {
await sandbox.update({ networkPolicy: { allow: [new URL(url).hostname] } });
return text(await run(sandbox, '.venv/bin/python', ['load.py'], {
DATASET_URL: url,
DATASET_FORMAT: format,
}));
} finally {
await sandbox.update({ networkPolicy: 'deny-all' });
}
} catch (error) {
return text(String(error), true);
}
},
);
server.registerTool(
'query_dataset',
{
description:
'Query the `dataset` table through a read-only DuckDB connection with external access disabled. Aggregate, filter, and sort in SQL. Results are capped at `limit` rows and 20,000 characters.',
inputSchema: z.object({
datasetId: DatasetId,
sql: z.string(),
limit: z.number().int().min(1).max(500).optional(),
}),
},
async ({ datasetId, sql, limit }) => {
try {
const sandbox = await getAnalysisSandbox(datasetId);
const result = await run(sandbox, '.venv/bin/python', ['query.py'], {
SQL: sql,
ROW_LIMIT: String(limit ?? 100),
});
return text(result);
} catch (error) {
return text(String(error), true);
}
},
);
server.registerTool(
'run_python',
{
description:
'Run a Python 3.14 script in the dataset sandbox with duckdb, pandas, and pyarrow installed. The DuckDB database is at data/analysis.duckdb. Open it with duckdb.connect("data/analysis.duckdb", read_only=True, config={"temp_directory": "duckdb-tmp"}). Print results to stdout. Keep output under 20,000 characters. Ordinary outbound traffic is blocked after setup and loading. Run calls for this dataset sequentially.',
inputSchema: z.object({
datasetId: DatasetId,
code: z.string(),
}),
},
async ({ datasetId, code }) => {
try {
const sandbox = await getAnalysisSandbox(datasetId);
const path = `analysis-${randomUUID()}.py`;
await sandbox.writeFiles([{ path, content: Buffer.from(code) }]);
return text(await run(sandbox, '.venv/bin/python', [path]));
} catch (error) {
return text(String(error), true);
}
},
);
});
async function verifyToken(
_request: Request,
bearerToken?: string,
): Promise<AuthInfo | undefined> {
const expectedToken = process.env.MCP_SERVER_TOKEN;
if (!expectedToken || !bearerToken) return undefined;
const expected = Buffer.from(expectedToken);
const received = Buffer.from(bearerToken);
if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
return undefined;
}
return {
token: bearerToken,
clientId: 'data-analysis-demo',
scopes: ['analysis'],
extra: { subject: 'demo-owner' },
};
}
const authenticatedHandler = withMcpAuth(handler, verifyToken, {
required: true,
requiredScopes: ['analysis'],
});
export { authenticatedHandler as GET, authenticatedHandler as POST };

MCP clients connect to /api/mcp over Streamable HTTP. The route runs as a Edgenivo Function with Fluid compute, so Active CPU billing pauses while the function waits on Sandbox commands. Provisioned memory is still billed until the call finishes.

Copy link to heading6. Test the server locally

Open two terminals in your project directory. Run the development server in one and MCP Inspector in the other:

Terminal
# Terminal 1: start the development server
pnpm dev
# Terminal 2: start MCP Inspector
pnpm dlx @modelcontextprotocol/inspector@2.12.0
Terminal
# Terminal 1: start the development server
npm run dev
# Terminal 2: start MCP Inspector
npx @modelcontextprotocol/inspector@2.12.0
Terminal
# Terminal 1: start the development server
yarn dev
# Terminal 2: start MCP Inspector
yarn dlx @modelcontextprotocol/inspector@2.12.0
Terminal
# Terminal 1: start the development server
bun dev
# Terminal 2: start MCP Inspector
bunx @modelcontextprotocol/inspector@2.12.0

Open the URL printed by the Inspector, which includes its session token. On the Servers screen, add a Streamable HTTP server at http://localhost:3000/api/mcp. In its settings, add an Authorization header with the value Bearer your_mcp_token_here, using the token from the server's .env.local. Connect to the server, open Tools, and select load_dataset. Call it with your dataset URL:

{
"datasetId": "orders-2026",
"url": "https://your_blob_store_id_here.public.blob.vercel-storage.com/orders.parquet",
"format": "parquet"
}

The first call takes longer because it creates the sandbox and installs packages. The response includes the schema, row count, and source file size. For an orders dataset, it might look like this:

{
"table": "dataset",
"rows": 48213977,
"columns": [
{ "name": "order_id", "type": "BIGINT" },
{ "name": "region", "type": "VARCHAR" },
{ "name": "ordered_at", "type": "TIMESTAMP" },
{ "name": "amount", "type": "DOUBLE" }
],
"sourceBytes": 2147483648
}

Next, call query_dataset with the same datasetId and an aggregation that matches your columns. For the example schema above, use:

SELECT region, sum(amount) AS revenue
FROM dataset
GROUP BY region
ORDER BY revenue DESC

The response contains one row per region, regardless of the source file's row count.

Copy link to heading7. Deploy and connect an MCP client

Add MCP_SERVER_TOKEN to the project's Production environment with vercel env add MCP_SERVER_TOKEN production, then deploy the authenticated server:

vercel deploy --prod

Make the server's MCP_SERVER_TOKEN available in Cursor's shell or system environment. Then add the endpoint and bearer header to .cursor/mcp.json:

.cursor/mcp.json
{
"mcpServers": {
"data-analysis": {
"url": "https://your_project_name_here.vercel.app/api/mcp",
"headers": {
"Authorization": "Bearer ${env:MCP_SERVER_TOKEN}"
}
}
}
}

Ask the agent to load the orders URL and compare month-over-month growth by region. It can inspect the schema and reuse the same dataset ID for later queries or Python scripts. Call load_dataset again only when you intend to reload the data.

Copy link to headingConnect the MCP server to an agent

The deployed /api/mcp endpoint works with agent frameworks that support MCP clients. The AI SDK, TanStack AI, and eve discover the server's tools and manage tool calls during the model's response. Their configuration determines where you set authentication and analysis instructions.

FrameworkPackagesHow the model gets the toolsWhere analysis instructions go
AI SDKai, @ai-sdk/mcpclient.tools() passed to a ToolLoopAgentinstructions on the agent
TanStack AI@tanstack/ai, @tanstack/ai-mcp, @tanstack/ai-vercel-gatewayMCP client passed to chat(), which discovers tools when the run startssystemPrompts on chat()
eveeveA connection file under agent/connections/ that eve searches at runtimeThe connection's description

Copy link to headingShared configuration

The AI SDK and TanStack AI examples read the same environment variables and send model requests through AI Gateway. Add them to a .env file:

.env
AI_GATEWAY_API_KEY=your_api_key_here
MCP_SERVER_URL=https://your_project_name_here.vercel.app/api/mcp
MCP_SERVER_TOKEN=your_mcp_token_here

Set MCP_SERVER_TOKEN to the same secret used by the MCP server. Both examples import the instructions below from lib/analyst-instructions.ts to encourage one load per dataset and answers based on aggregates.

lib/analyst-instructions.ts
export const ANALYST_INSTRUCTIONS = `You are a data analyst. Datasets live in a remote sandbox, not in this conversation.
- Call load_dataset once per dataset, then read the returned schema before writing queries.
- Answer with query_dataset. Aggregate, filter, and sort in SQL instead of selecting raw rows.
- Use run_python only for work SQL can't do, such as statistics or reshaping with pandas.
- Run calls for the same dataset sequentially. Finish loading before querying or running Python.
- Reuse the same datasetId for every call about the same dataset.`;

Copy link to headingUse the AI SDK

Install ai and @ai-sdk/mcp. The MCP client makes the server's tools available to ToolLoopAgent, which runs them when the model requests them:

Terminal
pnpm i ai @ai-sdk/mcp
Terminal
npm i ai @ai-sdk/mcp
Terminal
yarn add ai @ai-sdk/mcp
Terminal
bun add ai @ai-sdk/mcp

Create the agent:

analyst.mts
import { createMCPClient } from '@ai-sdk/mcp';
import { ToolLoopAgent, isStepCount } from 'ai';
import { ANALYST_INSTRUCTIONS } from './lib/analyst-instructions';
const url = process.env.MCP_SERVER_URL;
if (!url) throw new Error('Set MCP_SERVER_URL in your .env file.');
const token = process.env.MCP_SERVER_TOKEN;
const client = await createMCPClient({
transport: {
type: 'http',
url,
...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}),
},
});
try {
const analyst = new ToolLoopAgent({
model: 'anthropic/claude-opus-5',
instructions: ANALYST_INSTRUCTIONS,
tools: await client.tools(),
stopWhen: isStepCount(20),
});
const result = await analyst.generate({
prompt:
'Load https://your_blob_store_id_here.public.blob.vercel-storage.com/orders.parquet as orders-2026, then tell me which region grew fastest month over month.',
});
console.log(result.text);
} finally {
await client.close();
}

Run it with your environment variables loaded:

Terminal
pnpm dlx tsx --env-file=.env analyst.mts
Terminal
npx tsx --env-file=.env analyst.mts
Terminal
yarn dlx tsx --env-file=.env analyst.mts
Terminal
bunx tsx --env-file=.env analyst.mts

The isStepCount(20) limit allows several model turns for loading data, inspecting its schema, and querying before answering.

When adapting this example to analyst.stream() in a Next.js route, keep the MCP client open until the stream ends. Close it from the agent's onEnd callback as described in MCP client cleanup.

Copy link to headingUse TanStack AI

TanStack AI's chat() accepts MCP clients directly, discovers their tools when the run starts, and executes tool calls through the client. Install TanStack AI, its MCP client, and the AI Gateway adapter:

Terminal
pnpm i @tanstack/ai @tanstack/ai-mcp @tanstack/ai-vercel-gateway
Terminal
npm i @tanstack/ai @tanstack/ai-mcp @tanstack/ai-vercel-gateway
Terminal
yarn add @tanstack/ai @tanstack/ai-mcp @tanstack/ai-vercel-gateway
Terminal
bun add @tanstack/ai @tanstack/ai-mcp @tanstack/ai-vercel-gateway

Create the agent:

analyst.mts
import { chat, maxIterations } from '@tanstack/ai';
import { createMCPClient } from '@tanstack/ai-mcp';
import { vercelGatewayText } from '@tanstack/ai-vercel-gateway';
import { ANALYST_INSTRUCTIONS } from './lib/analyst-instructions';
const url = process.env.MCP_SERVER_URL;
if (!url) throw new Error('Set MCP_SERVER_URL in your .env file.');
const token = process.env.MCP_SERVER_TOKEN;
const client = await createMCPClient({
transport: {
type: 'http',
url,
...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}),
},
});
const { text } = await chat({
adapter: vercelGatewayText('anthropic/claude-opus-5'),
threadId: 'orders-2026-analysis',
systemPrompts: [ANALYST_INSTRUCTIONS],
messages: [
{
role: 'user',
content:
'Load https://your_blob_store_id_here.public.blob.vercel-storage.com/orders.parquet as orders-2026, then tell me which region grew fastest month over month.',
},
],
mcp: { clients: [client], connection: 'close' },
agentLoopStrategy: maxIterations(20),
stream: false,
});
console.log(text);

Run it the same way:

Terminal
pnpm dlx tsx --env-file=.env analyst.mts
Terminal
npx tsx --env-file=.env analyst.mts
Terminal
yarn dlx tsx --env-file=.env analyst.mts
Terminal
bunx tsx --env-file=.env analyst.mts
  • threadId identifies the conversation and is required by chat() in @tanstack/ai 1.0.
  • maxIterations(20) raises the default limit of 5 model turns so the agent can make several queries before answering.
  • connection: 'close' closes the MCP client when the run finishes, fails, or aborts.
  • Remove stream: false to get a stream of events for a chat UI.

Copy link to headingUse eve

eve discovers remote MCP tools from connection files and prefixes each tool name with the connection name. The model uses eve__search to find tools such as data-analysis__query_dataset and calls them through eve__tool.

If you don't have an eve project, create one with npx eve@latest init. Add this connection in agent/connections/data-analysis.ts:

agent/connections/data-analysis.ts
import { defineMcpClientConnection } from 'eve/connections';
export default defineMcpClientConnection({
url: process.env.DATA_ANALYSIS_MCP_URL!,
description:
'Large-dataset analysis in an isolated Edgenivo Sandbox. Call load_dataset once per CSV or Parquet URL, then answer with query_dataset (DuckDB SQL that aggregates instead of selecting raw rows). Run calls for the same dataset sequentially and finish loading before analysis. Use run_python only for statistics SQL cannot do.',
auth: {
getToken: async () => ({ token: process.env.MCP_SERVER_TOKEN! }),
},
tools: { allow: ['load_dataset', 'query_dataset', 'run_python'] },
});

Add both values to the eve project's environment variables:

vercel env add DATA_ANALYSIS_MCP_URL
vercel env add MCP_SERVER_TOKEN

The filename sets the connection name to data-analysis. eve shows the description beside that name, and the model uses it to decide which connection to search. auth.getToken sends the token as Authorization: Bearer <token> on every request and defaults to one shared app credential. tools.allow limits the model to the three analysis tools.

To let each user authorize separately, first add OAuth token verification and protected-resource metadata to this MCP server. Then create and attach a Edgenivo Connect connector, use connect() from @vercel/connect/eve, and authenticate the eve channel as a user. The eve with MCP guide describes the connector and channel setup. Dataset authorization still belongs in the MCP server.

Send the agent a prompt through any of its channels, such as "Load the orders file at this URL and tell me which region grew fastest month over month." To require a person to approve each run_python call before it runs, add an approval policy to the connection, as described in eve's MCP connections docs.

Copy link to headingHow to share large datasets across sandboxes with Edgenivo Drives

The setup in steps 4 and 5 keeps each dataset in one persistent sandbox. Keep it when one agent at a time queries data that fits on the sandbox's 64 GB disk.

Use a Edgenivo Drive to store the DuckDB database outside that sandbox when you need more capacity, concurrent query sandboxes, or data that outlives snapshot expiration. One sandbox loads the database onto the drive, and other sandboxes query read-only snapshots.

Persistent sandbox (steps 4 and 5)Edgenivo Drive
Capacity64 GB disk, shared with the image and packages1 TiB by default (1 GiB on Hobby), up to a 16 TiB quota
Concurrent readersOne sandbox per datasetMany sandboxes, each mounting a read-only drive snapshot
WritersThe same sandboxOne sandbox at a time with a read-write mount
RetentionSnapshot expires 30 days after last use by default (7 days in this guide)Until you delete the drive
Storage price (iad1, Pro)$0.08/GB-month$0.05/GB-month, plus $0.0015/GB read and $0.004/GB written
RegionAny supported Sandbox regionSandbox main region must match the drive's region

Copy link to headingAdd a drive helper

loadDatasetToDrive loads a dataset in a short-lived sandbox with a read-write mount at /data. It runs load.py from step 3, then stops the sandbox to release the mount for the next load.

getQuerySandbox gives each dataset and reader a persistent sandbox with a read-only snapshot at /data. Both helpers link data to that mount so data/analysis.duckdb remains the database path for existing scripts.

lib/dataset-drive.ts
import { Drive, Sandbox } from '@vercel/sandbox';
import { LOAD_SCRIPT, QUERY_SCRIPT } from './analysis-scripts';
import { run } from './analysis-sandbox';
const IMAGE = 'vercel/sandbox/python:3.14';
async function setUp(sandbox: Sandbox) {
await sandbox.writeFiles([
{ path: 'load.py', content: Buffer.from(LOAD_SCRIPT) },
{ path: 'query.py', content: Buffer.from(QUERY_SCRIPT) },
]);
await run(sandbox, 'ln', ['-s', '/data', 'data']); // scripts read and write data/
await run(sandbox, 'python3', ['-m', 'venv', '.venv']);
await run(sandbox, '.venv/bin/pip', ['install', 'duckdb', 'pandas', 'pyarrow', 'pytz'], undefined, false);
}
// Writes the dataset to a drive once. Only one sandbox can mount a drive read-write.
export async function loadDatasetToDrive(datasetId: string, url: string, format: 'csv' | 'parquet') {
const drive = await Drive.getOrCreate({ name: `dataset-${datasetId}` });
const loader = await Sandbox.create({
image: IMAGE,
region: drive.region, // must match the drive's region
resources: { vcpus: 8 },
timeout: 60 * 60 * 1000,
persistent: false,
mounts: { '/data': drive },
});
try {
await setUp(loader);
await loader.update({ networkPolicy: { allow: [new URL(url).hostname] } });
return await run(loader, '.venv/bin/python', ['load.py'], {
DATASET_URL: url,
DATASET_FORMAT: format,
});
} finally {
await loader.stop(); // releases the read-write mount
}
}
// Each reader gets its own sandbox that mounts a read-only snapshot of the drive.
export async function getQuerySandbox(datasetId: string, readerId: string) {
const drive = await Drive.get({ name: `dataset-${datasetId}` });
return Sandbox.getOrCreate({
name: `query-${datasetId}-${readerId}`,
image: IMAGE,
region: drive.region,
resources: { vcpus: 8 },
timeout: 30 * 60 * 1000,
keepLastSnapshots: { count: 1 },
mounts: { '/data': drive.snapshot() },
onCreate: async (sandbox) => {
try {
await setUp(sandbox);
} finally {
await sandbox.update({ networkPolicy: 'deny-all' });
}
},
});
}

Copy link to headingPoint the tools at the drive

Update the helper imports in app/api/mcp/route.ts:

app/api/mcp/route.ts
import { run } from '@/lib/analysis-sandbox';
import { getQuerySandbox, loadDatasetToDrive } from '@/lib/dataset-drive';

For this single-owner example, use the fixed reader ID demo. In load_dataset, replace the contents of the try block with:

return text(await loadDatasetToDrive(datasetId, url, format));

Replace the sandbox lookup in query_dataset and run_python:

const sandbox = await getQuerySandbox(datasetId, 'demo');

Every holder of the shared token uses this reader sandbox. For separate readers, derive keys from a verified user or tenant identity and check permission to access the requested dataset.

Because OAuth clientId identifies an application shared by many users, it cannot establish dataset ownership.

Copy link to headingWhat to know before using drives

Copy link to headingRefresh readers after a reload

Drive snapshots reflect the data when they were mounted. After reloading a dataset, update each query sandbox's mount and stop its session:

await sandbox.update({
mounts: { '/data': drive.snapshot() },
});
await sandbox.stop();

The updated mount takes effect next session. Alternatively, delete and recreate the existing query sandboxes.

Copy link to headingLoad once at a time

Only one sandbox can mount a drive read-write. The loader's finally block attempts to release that mount, but a terminated function can leave it busy until the sandbox stops or its one-hour session expires.

Copy link to headingComplete the first load before querying

Mounting a snapshot of an unwritten drive fails with drive_not_initialized. Load the dataset before opening a reader.

Copy link to headingKeep query files writable

Open the database with read_only=True and set temp_directory to a writable sandbox path such as duckdb-tmp. The revised query.py does both. Model-written Python needs the same settings when a query may spill to disk.

Copy link to headingMatch the main region

Set the sandbox's main region to the drive's region with region: drive.region, as the helper does. The project's default sandbox region does not apply.

Copy link to headingSize the drive on Hobby

Drives default to 1 GiB on Hobby. Set maxSize in bytes when first creating the drive; its size and region cannot change later.

Copy link to headingRemove source files you no longer need

load.py retains the downloaded file beside the database. Delete data/source.* after a successful import if you do not need that copy.

Copy link to headingBest practices and troubleshooting

Copy link to headingReturn summaries instead of rows

MCP responses become input to the model. query_dataset limits row counts, but run_python can print any text, so its instructions should ask for concise results. Keep the output check in run even when the tool descriptions ask for summaries.

Charts and exports can stay in the sandbox while the tool returns a description. To let users download them, add an authenticated artifact-retrieval tool; this example does not expose files to the client.

Copy link to headingBlock network access after loading data

In the persistent-sandbox setup, load_dataset allows HTTPS traffic to the source host during a download and restores deny-all in a finally block. The Drive variant stops its separate loader after the attempt. If the source redirects, use the final HTTPS URL or explicitly allow the redirect host.

Keep loading separate from query execution so other commands do not inherit temporary network access. The policy persists across sessions and blocks ordinary outbound traffic. Sandbox network rules retain documented infrastructure exceptions, and tool output still returns to the MCP client.

Copy link to headingSize the sandbox to the dataset

Each vCPU includes 2 GB of memory. DuckDB can spill many larger-than-memory queries to disk, but some aggregates and combinations of operators can still run out of memory.

Allow disk space for the downloaded file, DuckDB database, installed packages, and temporary query files. Start with 2 to 4 vCPUs for modest datasets, then measure your queries before increasing resources.vcpus. Drive-backed readers still need space on their local disk for duckdb-tmp.

For data that exceeds local disk capacity or needs several query sandboxes, store the dataset on a drive.

Copy link to headingFan out across sandboxes for partitioned data

For data partitioned by date, region, or customer, query each partition in a separate sandbox and combine the results. Sum partition totals and counts directly. To compute an overall average, combine each partition's sum and count; averages and distinct counts cannot generally be added.

Mount one drive snapshot in every sandbox to avoid copying shared data. Give each query a partition filter so the combined result counts each row once.

Pro and Enterprise allow up to 10,000 concurrent sandboxes. Allocation starts at 150 vCPUs per minute and ramps to 5,000 under sustained use. With no other allocations, the initial rate covers 18 new eight-vCPU sandboxes.

Copy link to headingHandle queries that outlast the function

The function's maxDuration limits how long a tool call can wait. With Fluid compute, the limit is 300 seconds on Hobby and 800 on Pro and Enterprise. Eligible Node.js 22 or 24 functions can use the extended duration beta for up to 1,800 seconds.

For longer work, start the command with runCommand({ cmd, args, detached: true }) and return its cmdId together with the sandbox ID. Add an authenticated tool that retrieves that sandbox and calls sandbox.getCommand(cmdId) to check completion and read the result.

Set the sandbox session timeout to cover the job. This guide uses 30-minute query sessions; Pro and Enterprise permit up to 24 hours.

Copy link to headingControl snapshot storage costs

Persistent sandboxes save snapshots when sessions stop. Pro storage costs $0.08 per GB-month. The step 4 helper keeps the latest snapshot for seven days after its last use. Drive query sandboxes use the default 30-day expiration.

When analysis is finished, call sandbox.delete({ deleteOrphanSnapshots: true }) to remove the sandbox and asynchronously delete snapshots that no other sandbox uses. Referenced snapshots remain stored and billed. For work that does not need to resume, create the sandbox with persistent: false.

Copy link to headingSecure the MCP server

The shared bearer token in this example gives its holder access to every dataset and all three tools. For a service used by independent users or tenants, verify tokens from an authorization server, authorize each dataset operation, and include the verified owner in sandbox and drive keys. Publish OAuth protected-resource metadata so clients can discover the authorization server. See Enabling authorization for the token-verification and metadata endpoints.

Copy link to headingCommon errors

ErrorCauseFix
load_dataset fails with a connection or certificate errorThe URL redirects to a host that isn't in the allow list, or the URL uses plain HTTPAllow the redirect host or use the final HTTPS URL. Sandbox allow lists match TLS traffic only
pip install fails during onCreateThe first call created the sandbox while package downloads were blocked or failedDelete the sandbox with vercel sandbox remove analysis-orders-2026 and call load_dataset again
The tool call times outThe query ran longer than the function's maxDurationRaise maxDuration, add more vCPUs, or run the command detached
Sandbox.getOrCreate() rejects resources.vcpusThe value is above your plan's limitUse 4 vCPUs or fewer on Hobby and 8 or fewer on Pro
Sandbox creation stops working on HobbyThe monthly Hobby allotment is used up, which pauses sandbox creationUpgrade to Pro or wait for the allotment to reset

Copy link to headingNext steps

Related documentation

More Edgenivo Sandbox guides