Guide · Data Serialization Integration

MCP Server Apache Parquet — read S3/GCS files via DuckDB, column projection, predicate pushdown

Three Apache Parquet behaviours trip up MCP server authors: reading Parquet with SELECT * scans all columns even though Parquet's columnar storage means you only pay for the columns you request — a table with 200 columns but an agent that only needs 3 will transfer 200 columns of data from S3 unless you write SELECT col1, col2, col3 explicitly; the fix is always to list the columns the tool response will use; DuckDB pushes WHERE predicates into Parquet row group statistics — a filter like WHERE date_col >= '2026-01-01' skips entire row groups whose max value is below the threshold without reading the data, but this only works if the file was written with row_group_size small enough to have useful min/max statistics (default 122,880 rows per row group is usually fine); and Hive-partitioned datasets require the hive_partitioning=true option — a path like s3://bucket/data/date=2026-07-01/*.parquet will not expose date as a queryable column unless you pass hive_partitioning=true to read_parquet(), which is the most effective partition pruning mechanism for date-ranged queries.

TL;DR

Use DuckDB's read_parquet() with HTTPFS extension to query Parquet files on S3 or GCS directly from MCP tool handlers. Always project columns explicitly — never use SELECT *. Pass hive_partitioning=true for partitioned datasets. Use parquet_schema() to inspect column types before building queries. Limit rows with LIMIT and use parquet_metadata() to understand file statistics before reading large datasets.

Reading Parquet from S3 via DuckDB HTTPFS

DuckDB's httpfs extension enables direct HTTP(S) reads from S3-compatible object stores and GCS without downloading files locally. The extension performs byte-range requests, so DuckDB can fetch only the column chunks and row groups it needs — combining with SELECT column projection and WHERE predicate pushdown makes remote Parquet queries practical even for files in the gigabyte range.

import Database from 'duckdb-async';

const db = await Database.create(':memory:');
const conn = await db.connect();

// Configure HTTPFS for S3 access — run once at startup
await conn.run(`
  INSTALL httpfs;
  LOAD httpfs;
  SET s3_region='${process.env.AWS_REGION ?? 'us-east-1'}';
  SET s3_access_key_id='${process.env.AWS_ACCESS_KEY_ID}';
  SET s3_secret_access_key='${process.env.AWS_SECRET_ACCESS_KEY}';
`);

// For GCS: use the S3-compatible endpoint
// SET s3_endpoint='storage.googleapis.com';
// SET s3_access_key_id=;
// SET s3_secret_access_key=;

// MCP tool: query a Parquet file with column projection and predicate pushdown
server.tool(
  'query_parquet',
  {
    s3_path:    z.string().startsWith('s3://'),
    columns:    z.array(z.string()).min(1).max(20),
    where_sql:  z.string().optional(),
    limit:      z.number().int().min(1).max(10000).default(1000),
    offset:     z.number().int().min(0).default(0),
  },
  async ({ s3_path, columns, where_sql, limit, offset }) => {
    // Sanitize column names — only allow identifier-safe names
    const safeCols = columns
      .map(c => c.replace(/[^a-zA-Z0-9_]/g, ''))
      .filter(Boolean)
      .map(c => `"${c}"`)
      .join(', ');

    if (!safeCols) throw new Error('No valid column names provided');

    // Build parameterised query (where_sql is validated separately)
    const whereClause = where_sql ? `WHERE ${where_sql}` : '';
    const sql = `
      SELECT ${safeCols}
      FROM read_parquet('${s3_path.replace(/'/g, "''")}'
        , hive_partitioning = true
        , union_by_name    = true   -- handle schema differences across files
      )
      ${whereClause}
      LIMIT ${limit}
      OFFSET ${offset}
    `;

    const rows = await conn.all(sql);
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({ row_count: rows.length, rows }, (_, val) =>
          typeof val === 'bigint' ? val.toString() : val
        ),
      }],
    };
  }
);

// MCP tool: query a Hive-partitioned dataset with partition filter
server.tool(
  'query_partitioned_dataset',
  {
    s3_glob:    z.string(),  // e.g. 's3://my-bucket/events/**/*.parquet'
    date_from:  z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
    date_to:    z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
    columns:    z.array(z.string()).min(1).max(20),
    limit:      z.number().int().min(1).max(5000).default(500),
  },
  async ({ s3_glob, date_from, date_to, columns, limit }) => {
    const safeCols = columns.map(c => `"${c.replace(/[^a-zA-Z0-9_]/g, '')}"`).join(', ');

    // Partition filter on 'date' column — DuckDB will prune partitions
    // before fetching any data, not after
    const rows = await conn.all(`
      SELECT ${safeCols}
      FROM read_parquet('${s3_glob.replace(/'/g, "''")}', hive_partitioning = true)
      WHERE date >= '${date_from}' AND date <= '${date_to}'
      LIMIT ${limit}
    `);

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({ row_count: rows.length, date_from, date_to, rows }, (_, val) =>
          typeof val === 'bigint' ? val.toString() : val
        ),
      }],
    };
  }
);

Inspecting Parquet schema and metadata before querying

DuckDB's parquet_schema() and parquet_metadata() table functions expose Parquet file metadata without reading row data. Use parquet_schema() to discover column names and types before building tool queries, and parquet_metadata() to understand row group count and row count to estimate whether a full scan is feasible within an MCP tool's time budget.

// MCP tool: inspect Parquet file schema — useful before building query tools
server.tool(
  'inspect_parquet_schema',
  { s3_path: z.string().startsWith('s3://') },
  async ({ s3_path }) => {
    const schema = await conn.all(`
      SELECT
        name         AS column_name,
        type         AS logical_type,
        converted_type,
        num_children
      FROM parquet_schema('${s3_path.replace(/'/g, "''")}')
      WHERE name != 'schema'  -- root schema node
      ORDER BY name
    `);

    const metadata = await conn.all(`
      SELECT
        COUNT(*)          AS row_group_count,
        SUM(num_values)   AS total_rows_approx,
        MIN(total_byte_size)  AS min_rg_bytes,
        MAX(total_byte_size)  AS max_rg_bytes,
        SUM(total_byte_size)  AS total_bytes
      FROM parquet_metadata('${s3_path.replace(/'/g, "''")}')
    `);

    const meta = metadata[0] as Record<string, unknown>;

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          columns: schema,
          stats: {
            row_groups:        meta.row_group_count,
            approx_total_rows: meta.total_rows_approx,
            total_size_bytes:  meta.total_bytes,
            total_size_mb:     Math.round(Number(meta.total_bytes) / 1_048_576 * 10) / 10,
          },
          recommendation:
            Number(meta.total_bytes) > 100_000_000
              ? 'File is >100 MB — always use column projection and WHERE predicates'
              : 'File is small enough for broad queries',
        }, (_, val) => typeof val === 'bigint' ? val.toString() : val),
      }],
    };
  }
);

// MCP tool: get row count without reading data (uses Parquet metadata footer)
server.tool(
  'count_parquet_rows',
  { s3_path: z.string() },
  async ({ s3_path }) => {
    // COUNT(*) on Parquet uses footer statistics — no row data fetched
    const result = await conn.all(`
      SELECT COUNT(*) AS row_count
      FROM read_parquet('${s3_path.replace(/'/g, "''")}')
    `);
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({ row_count: (result[0] as { row_count: number }).row_count }),
      }],
    };
  }
);

Schema drift — handling Parquet files with mismatched columns

Parquet datasets written by different jobs or at different times often have slightly different schemas — a column may be added in a newer file or renamed. DuckDB's union_by_name=true option merges schemas across files in a glob pattern, filling missing columns with NULL. Without this option, reading a glob of files with different schemas throws a schema mismatch error. The trade-off is that union_by_name adds overhead for DuckDB to read all file footers before executing the query — for datasets with hundreds of files, this can add several seconds to query start time.

// Handle schema drift in multi-file datasets
server.tool(
  'query_evolving_dataset',
  {
    s3_glob:  z.string(),
    columns:  z.array(z.string()).min(1).max(20),
    limit:    z.number().int().min(1).max(1000).default(100),
  },
  async ({ s3_glob, columns, limit }) => {
    // First: discover union schema without reading rows
    const schema = await conn.all(`
      SELECT DISTINCT name, type
      FROM parquet_schema('${s3_glob.replace(/'/g, "''")}')
      WHERE name != 'schema'
      ORDER BY name
    `);

    // Validate requested columns exist in the union schema
    const knownColumns = new Set(schema.map((r: Record<string, unknown>) => r.name as string));
    const invalidCols = columns.filter(c => !knownColumns.has(c));
    if (invalidCols.length > 0) {
      return {
        content: [{
          type: 'text',
          text: JSON.stringify({
            error: `Columns not found in schema: ${invalidCols.join(', ')}`,
            available_columns: Array.from(knownColumns),
          }),
        }],
      };
    }

    const safeCols = columns.map(c => `"${c.replace(/[^a-zA-Z0-9_]/g, '')}"`).join(', ');
    const rows = await conn.all(`
      SELECT ${safeCols}
      FROM read_parquet('${s3_glob.replace(/'/g, "''")}',
        union_by_name    = true,
        hive_partitioning = true
      )
      LIMIT ${limit}
    `);

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({ row_count: rows.length, rows }, (_, val) =>
          typeof val === 'bigint' ? val.toString() : val
        ),
      }],
    };
  }
);

Health probe — verify DuckDB HTTPFS and S3 access at startup

An MCP server that reads Parquet from S3 can fail for multiple independent reasons: the HTTPFS extension is not installed, the S3 credentials are expired, the bucket policy changed, or the target file was deleted. Separate the probe into two checks — one for HTTPFS loading and one for an actual S3 connectivity test using a known small reference file. This makes it easy to diagnose which layer failed when AliveMCP reports the endpoint as unhealthy.

async function checkParquetHealth(): Promise<{
  httpfs_loaded: boolean;
  s3_accessible: boolean;
  probe_rows: number;
  latency_ms: number;
  error?: string;
}> {
  const start = Date.now();

  // Check 1: HTTPFS extension loaded
  let httpfsLoaded = false;
  try {
    const extensions = await conn.all(`
      SELECT extension_name, loaded FROM duckdb_extensions()
      WHERE extension_name = 'httpfs'
    `);
    httpfsLoaded = extensions.some((e: Record<string, unknown>) => e.loaded === true);
  } catch {
    return { httpfs_loaded: false, s3_accessible: false, probe_rows: 0, latency_ms: Date.now() - start, error: 'duckdb_extensions() query failed' };
  }

  if (!httpfsLoaded) {
    return { httpfs_loaded: false, s3_accessible: false, probe_rows: 0, latency_ms: Date.now() - start, error: 'httpfs extension not loaded' };
  }

  // Check 2: S3 connectivity — read 1 row from a known small probe file
  const probeFile = process.env.PARQUET_HEALTH_PROBE_FILE; // e.g. 's3://my-bucket/probe/health.parquet'
  if (!probeFile) {
    return { httpfs_loaded: true, s3_accessible: false, probe_rows: 0, latency_ms: Date.now() - start, error: 'PARQUET_HEALTH_PROBE_FILE not set' };
  }

  try {
    const rows = await conn.all(`
      SELECT COUNT(*) AS n FROM read_parquet('${probeFile}') LIMIT 1
    `);
    return {
      httpfs_loaded: true,
      s3_accessible: true,
      probe_rows: (rows[0] as { n: number }).n,
      latency_ms: Date.now() - start,
    };
  } catch (err) {
    return {
      httpfs_loaded: true,
      s3_accessible: false,
      probe_rows: 0,
      latency_ms: Date.now() - start,
      error: String(err),
    };
  }
}

server.resource('parquet_health', 'parquet://health', async () => {
  const health = await checkParquetHealth();
  return {
    contents: [{
      uri: 'parquet://health',
      text: JSON.stringify(health),
    }],
  };
});