---
title: How to perform large-scale data analysis with Vercel Sandbox and MCP
description: Use Vercel 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.
url: "https://vercel.com/kb/guide/large-scale-data-analysis-vercel-sandbox-mcp"
published: 2026-10-26
last_updated: 2026-10-26
authors: Ben Sabic
related_resources:
  - title: "eve Vercel Drives Starter"
    url: "https://vercel.com/templates/eve/eve-vercel-drives-starter"
    description: "An eve agent that reads and saves your team's files on a Vercel Drive."
install_vercel_plugin: npx plugins add vercel/vercel-plugin
---

Use Vercel 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 Vercel, 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.

## Prerequisites

Before you begin, make sure you have:

- A [Vercel account](https://vercel.com/signup).
  
- An existing Next.js App Router project.
  
- CSV or Parquet dataset reachable over HTTPS, such as a public [Vercel Blob](https://vercel.com/docs/vercel-blob) URL or a presigned Amazon S3 URL.
  
- An MCP client such as Cursor or Claude Code, or use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector).
  

For local development, you also need Node.js 24+ and the [Vercel CLI](https://vercel.com/docs/cli).

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

| Plan       | Max vCPUs per sandbox | Max memory per sandbox | Disk per sandbox | Max sandbox session | Max function duration                      |
| ---------- | --------------------- | ---------------------- | ---------------- | ------------------- | ------------------------------------------ |
| Hobby      | 4                     | 8 GB                   | 64 GB            | 45 minutes          | 300s                                       |
| Pro        | 8                     | 16 GB                  | 64 GB            | 24 hours            | 800s, or 1800s with extended duration beta |
| Enterprise | 32                    | 64 GB                  | 64 GB            | 24 hours            | 800s, or 1800s with extended duration beta |

Function limits assume Fluid compute. For this Next.js route, [extended duration](https://vercel.com/docs/functions/configuring-functions/duration#extended-max-duration-beta) 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.

## How it works

Each tool call runs as a Vercel 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.

| Tool            | Input                                  | What runs in the sandbox                                        | What the model receives           |
| --------------- | -------------------------------------- | --------------------------------------------------------------- | --------------------------------- |
| `load_dataset`  | `datasetId`, `url`, `format`           | Downloads the file and loads it into the DuckDB table `dataset` | Schema, row count, and file size  |
| `query_dataset` | `datasetId`, `sql`, `limit` (optional) | Read-only DuckDB SQL against `dataset`                          | Up to 500 rows (100 by default)   |
| `run_python`    | `datasetId`, `code`                    | A Python script with DuckDB, pandas, and PyArrow available      | Up to 20,000 characters of stdout |

## Steps

### 1\. Install dependencies

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

**pnpm**

```bash
pnpm i @vercel/sandbox mcp-handler@2.1.1 @modelcontextprotocol/server@2 zod@4
```

**npm**

```bash
npm i @vercel/sandbox mcp-handler@2.1.1 @modelcontextprotocol/server@2 zod@4
```

**yarn**

```bash
yarn add @vercel/sandbox mcp-handler@2.1.1 @modelcontextprotocol/server@2 zod@4
```

**bun**

```bash
bun add @vercel/sandbox mcp-handler@2.1.1 @modelcontextprotocol/server@2 zod@4
```

### 2\. Authenticate with Vercel Sandbox

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

```bash
vercel link
vercel env pull
```

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

### 3\. 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:

```typescript
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="")
`;
```

### 4\. 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.

```typescript
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.

### 5\. 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.

```typescript
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 Vercel 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.

### 6\. Test the server locally

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

**pnpm**

```bash
# Terminal 1: start the development server
pnpm dev

# Terminal 2: start MCP Inspector
pnpm dlx @modelcontextprotocol/inspector@2.12.0
```

**npm**

```bash
# Terminal 1: start the development server
npm run dev

# Terminal 2: start MCP Inspector
npx @modelcontextprotocol/inspector@2.12.0
```

**yarn**

```bash
# Terminal 1: start the development server
yarn dev

# Terminal 2: start MCP Inspector
yarn dlx @modelcontextprotocol/inspector@2.12.0
```

**bun**

```bash
# 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:

```json
{
  "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:

```json
{
  "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:

```sql
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.

### 7\. 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:

```bash
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`:

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

## Connect 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.

| Framework   | Packages                                                          | How the model gets the tools                                              | Where analysis instructions go |
| ----------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------ |
| AI SDK      | `ai`, `@ai-sdk/mcp`                                               | `client.tools()` passed to a `ToolLoopAgent`                              | `instructions` on the agent    |
| TanStack AI | `@tanstack/ai`, `@tanstack/ai-mcp`, `@tanstack/ai-vercel-gateway` | MCP client passed to `chat()`, which discovers tools when the run starts  | `systemPrompts` on `chat()`    |
| eve         | `eve`                                                             | A connection file under `agent/connections/` that eve searches at runtime | The connection's `description` |

### Shared configuration

The AI SDK and TanStack AI examples read the same environment variables and send model requests through [AI Gateway](https://vercel.com/docs/ai-gateway). Add them to a `.env` file:

```bash
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.

```typescript
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.`;
```

### Use 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:

**pnpm**

```bash
pnpm i ai @ai-sdk/mcp
```

**npm**

```bash
npm i ai @ai-sdk/mcp
```

**yarn**

```bash
yarn add ai @ai-sdk/mcp
```

**bun**

```bash
bun add ai @ai-sdk/mcp
```

Create the agent:

```typescript
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:

**pnpm**

```bash
pnpm dlx tsx --env-file=.env analyst.mts
```

**npm**

```bash
npx tsx --env-file=.env analyst.mts
```

**yarn**

```bash
yarn dlx tsx --env-file=.env analyst.mts
```

**bun**

```bash
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](https://ai-sdk.dev/docs/ai-sdk-core/mcp-tools#closing-the-mcp-client).

### Use 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:

**pnpm**

```bash
pnpm i @tanstack/ai @tanstack/ai-mcp @tanstack/ai-vercel-gateway
```

**npm**

```bash
npm i @tanstack/ai @tanstack/ai-mcp @tanstack/ai-vercel-gateway
```

**yarn**

```bash
yarn add @tanstack/ai @tanstack/ai-mcp @tanstack/ai-vercel-gateway
```

**bun**

```bash
bun add @tanstack/ai @tanstack/ai-mcp @tanstack/ai-vercel-gateway
```

Create the agent:

```typescript
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:

**pnpm**

```bash
pnpm dlx tsx --env-file=.env analyst.mts
```

**npm**

```bash
npx tsx --env-file=.env analyst.mts
```

**yarn**

```bash
yarn dlx tsx --env-file=.env analyst.mts
```

**bun**

```bash
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.
  

### Use 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`:

```typescript
import { defineMcpClientConnection } from 'eve/connections';

export default defineMcpClientConnection({
  url: process.env.DATA_ANALYSIS_MCP_URL!,
  description:
    'Large-dataset analysis in an isolated Vercel 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:

```bash
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 Vercel Connect connector, use `connect()` from `@vercel/connect/eve`, and authenticate the eve channel as a user. The [eve with MCP guide](https://vercel.com/docs/mcp/integrations/eve) 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](https://eve.dev/docs/connections/mcp).

## How to share large datasets across sandboxes with Vercel 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 [Vercel Drive](https://vercel.com/docs/sandbox/concepts/drives) 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)                                        | Vercel Drive                                               |
| --------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Capacity                    | 64 GB disk, shared with the image and packages                            | 1 TiB by default (1 GiB on Hobby), up to a 16 TiB quota    |
| Concurrent readers          | One sandbox per dataset                                                   | Many sandboxes, each mounting a read-only drive snapshot   |
| Writers                     | The same sandbox                                                          | One sandbox at a time with a read-write mount              |
| Retention                   | Snapshot 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 |
| Region                      | Any supported Sandbox region                                              | Sandbox main region must match the drive's region          |

### Add 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.

```typescript
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' });
      }
    },
  });
}
```

### Point the tools at the drive

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

```typescript
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:

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

Replace the sandbox lookup in `query_dataset` and `run_python`:

```typescript
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.

### What to know before using drives

#### Refresh 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:

```typescript
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.

#### Load 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.

#### Complete the first load before querying

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

#### Keep 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.

#### Match 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.

#### Size 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.

#### Remove 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.

## Best practices and troubleshooting

### Return 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.

### Block 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](https://vercel.com/docs/sandbox/concepts/network-firewall) retain documented infrastructure exceptions, and tool output still returns to the MCP client.

### Size the sandbox to the dataset

Each vCPU includes 2 GB of memory. DuckDB can [spill many larger-than-memory queries to disk](https://duckdb.org/docs/2.0/guides/performance/how_to_tune_workloads), 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](#how-to-share-large-datasets-across-sandboxes-with-vercel-drives).

### Fan 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.

### Handle 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](https://vercel.com/docs/functions/configuring-functions/duration#extended-max-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.

### Control 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`.

### Secure 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](https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel#enabling-authorization) for the token-verification and metadata endpoints.

### Common errors

| Error                                                       | Cause                                                                                  | Fix                                                                                                |
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `load_dataset` fails with a connection or certificate error | The URL redirects to a host that isn't in the `allow` list, or the URL uses plain HTTP | Allow the redirect host or use the final HTTPS URL. Sandbox allow lists match TLS traffic only     |
| `pip install` fails during `onCreate`                       | The first call created the sandbox while package downloads were blocked or failed      | Delete the sandbox with `vercel sandbox remove analysis-orders-2026` and call `load_dataset` again |
| The tool call times out                                     | The query ran longer than the function's `maxDuration`                                 | Raise `maxDuration`, add more vCPUs, or run the command detached                                   |
| `Sandbox.getOrCreate()` rejects `resources.vcpus`           | The value is above your plan's limit                                                   | Use 4 vCPUs or fewer on Hobby and 8 or fewer on Pro                                                |
| Sandbox creation stops working on Hobby                     | The monthly Hobby allotment is used up, which pauses sandbox creation                  | Upgrade to Pro or wait for the allotment to reset                                                  |

## Next steps

- Review every Sandbox method used in this guide in the [Vercel Sandbox JS SDK reference](https://vercel.com/docs/sandbox/sdk-reference)
  
- Plan for larger workloads with [Vercel Sandbox pricing and quotas](https://vercel.com/docs/sandbox/pricing)
  
- Add OAuth and test with the MCP Inspector in [Deploy MCP servers to Vercel](https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel)
  
- Keep datasets across runs with [How to give agents persistent storage with Vercel Drives](https://vercel.com/kb/guide/persistent-storage-with-vercel-drives)
  
- Accept user uploads as data sources with [How to process user-uploaded files with Vercel Sandbox and Vercel Blob](https://vercel.com/kb/guide/user-uploaded-files-vercel-sandbox-and-blob)
  
- Call these tools from your own app with the [AI SDK MCP client](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#using-mcp-tools)
  
- Build a longer-running analyst with [How to run a multi-step research agent on Vercel](https://vercel.com/kb/guide/how-to-run-a-multi-step-research-agent-on-vercel)
  
- Compare authentication options for each framework in [MCP integrations](https://vercel.com/docs/mcp/integrations), including OAuth with [Vercel Connect](https://vercel.com/docs/connect)
  
- Fork, list, and delete drives with [How Vercel Drives work and how to use them](https://vercel.com/kb/guide/vercel-drives)

## Related Documentation

- [Vercel Sandbox](/docs/vercel-sandbox)
- [Vercel Drives](https://vercel.com/docs/sandbox/concepts/drives)

## Related Resources

- [eve Vercel Drives Starter](https://vercel.com/templates/eve/eve-vercel-drives-starter): An eve agent that reads and saves your team's files on a Vercel Drive.

## More Vercel Sandbox guides

- [Run untrusted Python code safely with Vercel Sandbox](/kb/guide/python-and-vercel-sandbox): Execute untrusted and AI-generated code from Python in isolated microVMs, with files, processes, network policies, and snapshots.
- [How to use Vercel Container Registry](/kb/guide/how-to-use-vercel-container-registry): Push, store, and pull OCI container images with Vercel Container Registry, then deploy them to Vercel Functions and Vercel Sandbox.
- [Running Docker on Vercel](/kb/guide/docker): Learn how to run Docker on Vercel by deploying OCI container images as Vercel Functions, storing them in Vercel Container Registry (VCR), and running containers in Vercel Sandbox.