Skip to main content

Using OpenAI Agents SDK with DataDoe MCP

This tutorial shows how to build a small TypeScript agent with the OpenAI Agents JS SDK (opens in a new tab) and connect it to DataDoe MCP. The agent asks DataDoe for an export, downloads the file, queries it locally with DuckDB, and writes a JSON report.

Every file is listed in full, so you can build the project by copying the blocks below in order.

What you will build

By the end, you will have a small agent that:

  • connects to DataDoe MCP with hostedMcpTool
  • downloads an export file before the URL expires
  • loads the data into DuckDB and runs SQL over it
  • writes one final JSON file to response/

Prerequisites

Step 1: Set up the project

bash
1mkdir datadoe-openai-agent && cd datadoe-openai-agent
2npm init -y
3npm pkg set type=module
4npm install @openai/agents zod @duckdb/node-api@1.5.5-r.4
5mkdir src

Setting type=module matters. Without it, Node refuses the files below with SyntaxError: Cannot use import statement outside a module. Pin the DuckDB release tag as shown. There is no plain 1.5.5 on npm.

Step 2: Configure your keys

Create a .env file in the project root:

bash
1DATADOE_MCP_KEY=YOUR_DATADOE_MCP_KEY
2OPENAI_API_KEY=YOUR_OPENAI_API_KEY

Node loads this file with --env-file when you run the app, so no extra dependency is needed. Keep .env out of version control.

Step 3: Create the local tools

DataDoe MCP supplies the data. Three local tools turn it into a workflow: one downloads the export, one queries it, and one writes the final artifact.

Create src/tools.ts:

ts
1import { mkdir, writeFile } from 'node:fs/promises';
2import { resolve } from 'node:path';
3import { z } from 'zod';
4
5import { DuckDBInstance } from '@duckdb/node-api';
6import { tool } from '@openai/agents';
7
8export const downloadExport = tool({
9    name: 'download_export',
10    description:
11        'URGENT: DataDoe export links expire quickly, so call this immediately after you get one. Downloads the file and returns its local path.',
12    parameters: z.object({
13        url: z.string().describe('The export download URL returned by DataDoe MCP.'),
14        filename: z.string().describe('File name to save as, for example "sales.csv".')
15    }),
16    async execute({ url, filename }) {
17        const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
18        const target = resolve('data', safeName);
19
20        const response = await fetch(url);
21
22        if (!response.ok) {
23            throw new Error(`Download failed: HTTP ${response.status}`);
24        }
25
26        await mkdir('data', { recursive: true });
27        await writeFile(target, Buffer.from(await response.arrayBuffer()));
28
29        return `Saved to ${target}`;
30    }
31});
32
33export const queryCsv = tool({
34    name: 'query_csv',
35    description:
36        'Runs a read-only SQL query against a downloaded CSV file using DuckDB. The file is exposed as a table named "data".',
37    parameters: z.object({
38        filePath: z.string().describe('Local path returned by download_export.'),
39        sql: z
40            .string()
41            .describe(
42                'A SELECT query against the table "data", for example: SELECT * FROM data LIMIT 5'
43            )
44    }),
45    async execute({ filePath, sql }) {
46        const instance = await DuckDBInstance.create(':memory:');
47        const connection = await instance.connect();
48
49        try {
50            await connection.run('CREATE TABLE data AS SELECT * FROM read_csv(?)', [filePath]);
51            const reader = await connection.runAndReadAll(sql);
52
53            // Use the Json variant: DuckDB returns BIGINT as a JS BigInt,
54            // which JSON.stringify cannot serialize.
55            return JSON.stringify(reader.getRowObjectsJson());
56        } finally {
57            connection.closeSync();
58        }
59    }
60});
61
62export const saveReport = tool({
63    name: 'save_report',
64    description:
65        'Writes the final report to ./response as JSON. Call this once, at the end, with the complete result.',
66    parameters: z.object({
67        summary: z.string().describe('A short summary of what the data shows.'),
68        rows: z
69            .array(
70                z.object({
71                    asin: z.string(),
72                    unitsSold: z.number()
73                })
74            )
75            .describe('Ranked best first.')
76    }),
77    async execute({ summary, rows }) {
78        await mkdir('response', { recursive: true });
79        const filePath = `response/report-${Date.now()}.json`;
80        await writeFile(filePath, JSON.stringify({ summary, rows }, null, 2), 'utf8');
81
82        return `Report written to ${filePath}`;
83    }
84});

Use getRowObjectsJson(), not getRowObjects(). Any SUM() or COUNT() comes back as a BIGINT, which becomes a JS BigInt that JSON.stringify refuses to serialize.

Step 4: Create the agent

Create src/index.ts. This is the complete entry point:

ts
1import { Agent, hostedMcpTool, run } from '@openai/agents';
2
3import { downloadExport, queryCsv, saveReport } from './tools.ts';
4
5const mcpKey = process.env.DATADOE_MCP_KEY;
6
7if (!mcpKey) {
8    throw new Error('DATADOE_MCP_KEY is not set. Add it to your .env file.');
9}
10
11const agent = new Agent({
12    name: 'Amazon Data Analyst',
13    model: 'gpt-5.4-mini',
14    instructions: [
15        'You analyze Amazon seller data through the DataDoe MCP server.',
16        'To answer a data question: create an export, take its download URL,',
17        'save it with download_export, then analyze it with query_csv.',
18        'Finish by calling save_report exactly once with the complete result.',
19        'Never write the final JSON directly in your reply.'
20    ].join(' '),
21    tools: [
22        hostedMcpTool({
23            serverLabel: 'datadoe',
24            serverUrl: 'https://mcp.datadoe.com/mcp/v1',
25            headers: { 'datadoe-mcp-key': mcpKey },
26            requireApproval: 'never'
27        }),
28        downloadExport,
29        queryCsv,
30        saveReport
31    ]
32});
33
34const prompt =
35    process.argv.slice(2).join(' ') || 'Which of my ASINs sold the most units in the last 7 days?';
36
37const result = await run(agent, prompt);
38
39console.log(result.finalOutput);

Note the local import path ends in .ts. Node requires the explicit extension when it runs TypeScript directly.

requireApproval: 'never' is the line that keeps an unattended script running. With approval enabled, every hosted MCP call waits for a human who is not there. The SDK treats an omitted value as 'never' today, so writing it out protects the script from a future change in that default.

Step 5: Run the app

bash
1node --env-file=.env src/index.ts

You can also pass your own question:

bash
1node --env-file=.env src/index.ts "Which ASINs sold the most units in the last 30 days?"

The run follows this shape:

  1. The agent asks DataDoe MCP for the export it needs.
  2. download_export saves the file before the link expires.
  3. query_csv loads it into DuckDB and runs SQL to rank the ASINs.
  4. save_report writes the final JSON to ./response/.

The file holds the summary and the ranked rows together, so a downstream script or cron job can read it directly.

Optional: stream the output

For a live view of the agent's reasoning, replace the last two lines of src/index.ts with a streamed run:

ts
1const stream = await run(agent, prompt, { stream: true });
2
3stream.toTextStream({ compatibleWithNodeStreams: true }).pipe(process.stdout);
4
5await stream.completed;

Use either toTextStream() or a for await loop over the stream. Never use both on the same run. Reading a stream twice throws ERR_INVALID_STATE at runtime, and TypeScript will not warn you.

A simple mental model

If you want to adapt this pattern for another DataDoe-powered agent, keep the same split:

  • DataDoe MCP for source data
  • local tools for downloading, transforming, and validating
  • one dedicated output tool for the final artifact

Each piece stays replaceable. Swap DuckDB for another query engine, or the JSON writer for a database insert, without touching the agent's instructions.


DataDoe MCP resources

Check the following resources for more information: