Seedfast

Seedfast

Seeding a Database With an AI Agent Over MCP

An agent with your repository already open can run the seeding job end to end, reading the schema over MCP, drafting a description of the business, running it and reporting the counts. Every figure here comes from one such session, where AI agent database seeding put 1,923 rows into seven empty tables in a little over two minutes.

The description was prepared ahead of the run, and a long one is long for a reason, since a stand built to show the engine at full stretch asks more than most real cases do. It is domain knowledge in plain language anyone on the team can read, argue with and change.

What AI agent database seeding looks like over MCP

Claude Code was the client, the server the published npm package seedfast 2.6.3, reporting MCP Server Version 1.0.0. The target was one PostgreSQL database holding the schemas crm and billing, eight tables, no rows, the DDL on scope examples. Configuration belongs to the MCP setup guide.

Two checks come first. seedfast_doctor takes no arguments.

CLI Status: OK
Version: seedfast 2.6.3
backend 2.0.0
Path: <where the binary was installed>
Auth: OK (SEEDFAST_API_KEY configured)
Platform: windows/amd64
Go Version: go1.25.1
MCP Server Version: 1.0.0

Only the Path line has been changed. seedfast_connections_test takes the connection string you would hand the CLI, your own credentials in it, and masks them in what it prints. The password and the database name in the input below stand in for the ones this session used.

{ "dsn": "postgresql://postgres:***@localhost:5432/app?sslmode=disable" }
Connection test successful for: postgresql://*:*@localhost:5432/app?sslmode=disable

The description starts from seedfast_schema_info, which returns one entry per table with columns, keys and an approximate row count, under its own note that reltuples may be -1 on never-analyzed tables; approximate only. One entry of the eight follows, cut to two of its six columns, both foreign keys kept. Without the optional dsn the server reads SEEDFAST_DSN or DATABASE_URL from its environment.

    {
      "name": "billing.subscriptions",
      "columns": [
        {
          "name": "customer_id",
          "data_type": "bigint",
          "nullable": false,
          "default": null
        },
        {
          "name": "plan_id",
          "data_type": "bigint",
          "nullable": false,
          "default": null
        }
      ],
      "primary_key": [
        "id"
      ],
      "foreign_keys": [
        {
          "columns": [
            "customer_id"
          ],
          "references_table": "crm.customers",
          "references_columns": [
            "id"
          ]
        },
        {
          "columns": [
            "plan_id"
          ],
          "references_table": "billing.plans",
          "references_columns": [
            "id"
          ]
        }
      ],
      "approximate_row_count": -1
    },

With the description drafted and read by a human, one call starts the work.

{
  "dsn": "postgresql://postgres:***@localhost:5432/app?sslmode=disable",
  "scope": "<the description>",
  "idempotencyKey": "docs-mcp-1"
}
Seeding started
Run ID: run_0387e1b9ee27f841
Status: pending
Scope: <the description, echoed back in full>
Use seedfast_run_status to check progress.

The Scope line here and below stands for the description echoed back in full. seedfast_run returns before any row is written, and a retry with the same idempotencyKey returns this run instead of starting a second.

seedfast_run_status is safe to call as often as you like. Five polls went out, two at Tables: 0/0, one at 0/7 once the table list was known, and a fourth near the end.

Run ID: run_0387e1b9ee27f841
Status: running
Scope: <the description, echoed back in full>
Started: 2026-09-06T21:49:07+02:00

Progress:
  Tables: 5/7 (71.4%)
  Rows: 723
  Current: crm.ticket_messages

The fifth carried the summary block a finished run ends on.

Run ID: run_0387e1b9ee27f841
Status: completed
Scope: <the description, echoed back in full>
Started: 2026-09-06T21:49:07+02:00
Completed: 2026-09-06T21:51:16+02:00

Progress:
  Tables: 7/7 (100.0%)
  Rows: 1923

Summary:
  Success: true
  Total Tables: 7
  Succeeded: 7
  Failed: 0
  Total Rows: 1923
  Elapsed: 128318ms

Two resources carry the same run for readers who would rather not poll, seedfast://runs/{runId}/summary as JSON with state, progress, summary and tables, seedfast://runs/{runId}/log as one event per line. States are pending, running, awaiting_input, completed, failed and cancelled.

A run with a question moves to awaiting_input, the question readable from seedfast_run_status or the seedfast://runs/{runId}/pending_question resource, and seedfast_run_answer replies inside five minutes, human_answer true to approve or false with a refinement in raw. This run had no question. seedfast_run_cancel stops a run still going, and the rows already written stay in place.

Underneath, the server runs the command a terminal runs, seedfast seed --scope "<description>", which is why these counts match what the terminal guides measured, seed your database among them.

The description is a document, not a syntax

Nobody types this out from memory before a demo. An agent drafts it from the schema it just read and the documents the team keeps, then runs it. Plain language rather than a syntax is what matters when a product manager wants the invoice mix changed.

Keep the file in the repository beside the migrations, and reread it when the schema changes, so that a new table gets its sentence before the next run.

What to give the agent

Seedfast reads the schema. Everything the schema cannot say has to reach the description, and collecting it is the agent's job. The README and the product docs carry the business. Where free text has to sound like your own, the fixtures and hand-written sample tickets show what it should sound like, and the numbers come from the tests and screens that will read these rows. What the schema alone already supplies is on data realism.

Outside all of that sit the locale the people belong in and the reference values a screen compares against, plans and currencies among them.

Principles of a good description

Seven techniques follow, each with the sentence that applies it and the result.

Name every table you care about. The opening sentence carried the schemas and the product.

Seed the crm and billing schemas for a B2B analytics product sold to mid-sized European companies.

Seven tables went into the plan. The description names two schemas and three countries, with no address table anywhere in it, and crm.addresses was not one of the seven.

Put a number on anything a test or a screen reads, per table, per parent row, and as a percentage for a share.

Every customer has exactly one subscription: about 80% active, 10% past due, 10% canceled.

120 of 120 customers came back holding one subscription, and the statuses split 96 active, 12 past_due and 12 canceled, the 80.0, 10.0 and 10.0 asked for. The same phrasing about messages gave 2 on each of the 120 tickets, and exact row counts has the histogram queries.

Describe the business in words. The content of the rows is drawn from those words, job titles, ticket subjects and plan names included.

job titles are heads of data, analytics leads and engineering managers

Four titles came back, 90 contacts under each, and the sentence that asked to leave dates of birth empty left all 360 empty.

SELECT job_title, count(*) FROM crm.contacts GROUP BY job_title ORDER BY count(*) DESC, job_title;
      job_title      | count
---------------------+-------
 Analytics Lead      |    90
 Engineering Manager |    90
 Head of Analytics   |    90
 Head of Data        |    90

Naming the language the values should be written in works the same way, and localized test data has the script checks for it.

Give the locale of the people a sentence of its own, apart from where the companies are based. This description sets the second of the two.

120 customers across Germany, Austria and the Netherlands, mostly smb, with a few enterprise accounts and a handful of startups.

Names, phone formats and street lines take an instruction of their own. The scopes that set them are on localized test data.

Lay out time explicitly. The months, the numbering and the width of the window all belong in the sentence.

Invoices are issued monthly from January 2026 to August 2026, numbered INV-2026-0001 upward, 70% paid, 20% overdue, 10% disputed.

Issue dates landed on the first of each month, 2026-01-01 through 2026-08-01, putting 8 invoices under each of the 120 subscriptions, and the numbers ran INV-2026-0001 to INV-2026-0960, 960 of 960 matching ^INV-2026-\d{4}$, none due before its issue date. Tickets fell inside their window, 2026-07-08 to the day of the run.

State reference data as exact values. Prices, currency codes and status vocabularies are what a screen compares against.

Three plans priced in EUR: Starter at 99, Growth at 399 and Scale at 1,490 per month.

The three plan rows came back at 99.00, 399.00 and 1490.00, with EUR on 960 of 960 invoices.

Size the description to the business. One sentence is enough for a probe. When a stand has to look complete on every screen, something like the brief below is what it takes, and scope examples puts four sizes through this schema.

A brief for your agent

Paste this into Claude Code or Cursor and fill in the angle brackets, a template rather than a transcript.

Seed our development database with Seedfast over MCP.

1. Read the schema with seedfast_schema_info and list the tables you found.
2. Read README.md, docs/, and the fixtures and tests that will read the seeded rows.
3. Draft a seeding description in plain language, with these parts in this order:
   the business and the slice of time it covers, in two or three sentences;
   reference data with exact values (plans, prices, currencies, status vocabularies);
   a row count for every table I care about;
   counts per parent row where a test reads them, and status shares as percentages;
   the layout in time (which months, how wide the window, how records are numbered);
   the locale of the people, their phone numbers and their street addresses.
4. Show me the description and the table list, then wait. Do not seed yet.
5. When I approve, call seedfast_run with the description text as the scope and an
   idempotencyKey I can retry with.
6. Poll seedfast_run_status. If the state is awaiting_input, read the question and
   answer it with seedfast_run_answer, checking with me first when the answer would
   change what I approved.
7. When the state is completed, run the counting queries and report the counts per
   table, the counts per parent row and the status shares next to the numbers the
   description asked for.

The server also ships two prompts. seed-production-db walks an agent through a careful run, taking scope and an optional dsn_description. scope-examples returns example scope strings, its use_case argument selecting general, ci or exploration.

From description to rows

Here is the description this run was given, word for word.

Seed the crm and billing schemas for a B2B analytics product sold to mid-sized European companies. 120 customers across Germany, Austria and the Netherlands, mostly smb, with a few enterprise accounts and a handful of startups. Company names read like real companies in those countries and each customer has its own email domain. Each customer has 2 to 4 contacts with emails on the company domain; job titles are heads of data, analytics leads and engineering managers; leave dates of birth empty. Three plans priced in EUR: Starter at 99, Growth at 399 and Scale at 1,490 per month. Every customer has exactly one subscription: about 80% active, 10% past due, 10% canceled. Invoices are issued monthly from January 2026 to August 2026, numbered INV-2026-0001 upward, 70% paid, 20% overdue, 10% disputed. Support tickets are mostly normal priority and were opened in the last 60 days; subjects read like real questions about dashboards, CSV exports and SSO. Each ticket has exactly 2 messages: a customer question followed by an agent reply.

The log resource carried 24 events, the plan first and the finish last.

{"type":"plan_proposed","ts":"2026-09-06T19:50:21Z","data":{"row_counts":{"billing.invoices":960,"billing.plans":3,"billing.subscriptions":120,"crm.contacts":360,"crm.customers":120,"crm.support_tickets":120,"crm.ticket_messages":240},"tables":["billing.invoices","billing.plans","billing.subscriptions","crm.contacts","crm.customers","crm.support_tickets","crm.ticket_messages"]}}
{"type":"run_completed","ts":"2026-09-06T19:51:16Z","data":{"elapsed_ms":128318,"failed_tables":0,"success":true,"success_tables":7,"total_rows":1923,"total_tables":7}}

One query counts the eight tables afterwards.

SELECT 'crm.customers' AS t, count(*) FROM crm.customers
UNION ALL SELECT 'crm.contacts', count(*) FROM crm.contacts
UNION ALL SELECT 'crm.addresses', count(*) FROM crm.addresses
UNION ALL SELECT 'crm.support_tickets', count(*) FROM crm.support_tickets
UNION ALL SELECT 'crm.ticket_messages', count(*) FROM crm.ticket_messages
UNION ALL SELECT 'billing.plans', count(*) FROM billing.plans
UNION ALL SELECT 'billing.subscriptions', count(*) FROM billing.subscriptions
UNION ALL SELECT 'billing.invoices', count(*) FROM billing.invoices;
           t           | count
-----------------------+-------
 crm.customers         |   120
 crm.contacts          |   360
 crm.addresses         |     0
 crm.support_tickets   |   120
 crm.ticket_messages   |   240
 billing.plans         |     3
 billing.subscriptions |   120
 billing.invoices      |   960

Each of the seven counts in the plan_proposed line is the count its table holds, and each is a figure the description names. The eighth row is crm.addresses, the table no sentence mentioned.

Shares take one query per column, dividing each count by the table total.

SELECT status, count(*) AS n,
       round(100.0 * count(*) / sum(count(*)) OVER (), 1) AS pct
FROM billing.invoices GROUP BY status ORDER BY n DESC;
  status  |  n  | pct
----------+-----+------
 paid     | 672 | 70.0
 overdue  | 192 | 20.0
 disputed |  96 | 10.0

Over billing.subscriptions the same query gives the second split.

  status  | n  | pct
----------+----+------
 active   | 96 | 80.0
 past_due | 12 | 10.0
 canceled | 12 | 10.0

The plans table is short enough to print whole, and it reads back as the description wrote it.

SELECT code, name, monthly_price, currency, seat_limit FROM billing.plans ORDER BY monthly_price;
    code    |  name   | monthly_price | currency | seat_limit
------------+---------+---------------+----------+------------
 starter_eu | Starter |         99.00 | EUR      |         25
 growth_eu  | Growth  |        399.00 | EUR      |        100
 scale_eu   | Scale   |       1490.00 | EUR      |        500

Six of the 960 invoice rows follow, in order of number.

SELECT number, status, issued_at, due_at, total_amount, currency FROM billing.invoices ORDER BY number LIMIT 6;
    number     | status | issued_at  |   due_at   | total_amount | currency
---------------+--------+------------+------------+--------------+----------
 INV-2026-0001 | paid   | 2026-01-01 | 2026-02-01 |       399.00 | EUR
 INV-2026-0002 | paid   | 2026-02-01 | 2026-03-01 |       399.00 | EUR
 INV-2026-0003 | paid   | 2026-03-01 | 2026-04-01 |       399.00 | EUR
 INV-2026-0004 | paid   | 2026-04-01 | 2026-05-01 |       399.00 | EUR
 INV-2026-0005 | paid   | 2026-05-01 | 2026-06-01 |       399.00 | EUR
 INV-2026-0006 | paid   | 2026-06-01 | 2026-07-01 |       399.00 | EUR

Scope examples ran this description three times from a terminal, with the per parent counts beside their queries.

At full stretch

A description for a stand that has to look complete on every screen runs long, roughly 1,200 words for one demo stand we keep, and most real cases need far less. The description this page ran is under 200 words. The parts arrive in a settled order.

  • the business and the time it covers, in two or three sentences
  • reference data with exact values
  • volumes table by table
  • statuses and their shares
  • the order of events in time
  • the locale of the people and their addresses

Reading that list is the review step, and an agent that puts the draft beside the table list from seedfast_schema_info gives the decision what it needs.

The same text everywhere

None of this is an MCP artefact. The same file goes into seedfast seed --scope "$(cat seeding-description.txt)" at a terminal, which scoping covers, and into a pipeline step behind an API key, which CI/CD database seeding covers.

If your client is not connected yet, the MCP setup guide has the config block for Claude Code, Cursor, VS Code, Codex and Claude Desktop. Then ask your agent for a draft against your own schema, and count what lands with the query above.