// ai summary · how i work·27 Oct 2026·7 min read

Spec first, then let the AI write it: BDD for an MCP server

Rackd2 has no real UI. The backend is MCP tools and the LLM is the interface. So the thing to get right is the tool contract. I write it first, as gym stories in Gherkin. Then an AI agent implements until they pass.

AI summary —written by an AI agent from my brief and the Rackd2 repo. The code blocks are copied from the repo, trimmed; the test run is real. The prose isn't hand-written.
Gherkin@amiceli/vitest-cucumberMCP · JSON-RPCbun:sqlite

The workflow

I write the .feature file. The LLM writes the step definitions and the code. The tests run red. The LLM reads the failure, changes the code, re-runs, and keeps going until it's green. Then I review.

Human → .feature → LLM → red → LLM edits code → greenhumanwrites the story.featurethe specLLMsteps + coderedtests failgreenall passread the failure, change the code, re-runonly the human edits .feature files · the LLM never edits a spec to make it pass
The loop. The red → code arrow repeats until everything is green.

The loop

1. I write the story: what I'd say to my AI at the gym, and what should come back. 2. An agent writes the tool until the story is green. 3. The stories stay, so the next agent edit can't quietly break the contract. The specs are plain English; the agent reads them as the requirement, and the suite is the guardrail.

This is the whole quick-log spec, verbatim:

gherkin
Feature: Logging a set in one sentence
  Scenario: Logging a set with plain text
    When my AI calls "quick_log" with:
      """
      {"text": "bench 100kg x5,5,4 @8"}
      """
    Then the call succeeds
    And the response mentions "100"

  Scenario: Same as last time
    Given I did a session 3 days ago with "bench 90kg x5,5,5"
    When my AI calls "quick_log" with:
      """
      {"text": "bench same as last time"}
      """
    Then the call succeeds
    And the response mentions "90"

"Same as last time" only works if the tool reads history. The story says so, in one line, before there's any code.

How the suite is organized

features/
├── mcp/                        18 feature files, one per area, 89 scenarios
│   ├── analytics.feature          40 lines
│   ├── auth.feature               39
│   ├── clarify.feature            26
│   ├── compose.feature            10
│   ├── export.feature             20
│   ├── library.feature            50
│   ├── live_workout.feature      105
│   ├── nutrition_body_goals.feature 183
│   ├── presentation_calendar.feature 126
│   ├── profile.feature            19
│   ├── programs.feature           67
│   ├── prompts_resources.feature  34
│   ├── quick_log.feature          17
│   ├── readiness.feature          34
│   ├── schedule.feature           18
│   ├── sessions.feature           52
│   ├── templates.feature          63
│   └── undo.feature               31
├── mcp.steps.ts                86 lines: the loop + every step sentence
└── support/
    └── world.ts                66 lines: DB, in-process server, mint/call/answer/fill
vitest.bdd.config.ts            13 lines: picks up features/**/*.steps.ts
  • features/mcp/*.feature: one file per area (sessions, programs, analytics, auth…). 18 files, 89 scenarios, 424 steps.
  • features/mcp.steps.ts: one test file that loops over every feature and defines every step sentence once.
  • features/support/world.ts: the test harness. In-memory DB with production migrations, an in-process MCP server, and helpers mint, call, answer, fill.
  • vitest.bdd.config.ts: tells Vitest to run features/**/*.steps.ts, keeps bun: modules external, writes JUnit in CI.
ts
import { defineConfig } from "vitest/config";
import { fileURLToPath } from "node:url";
const ci = !!process.env.CI;
export default defineConfig({
  resolve: { alias: { "~": fileURLToPath(new URL("./src", import.meta.url)) } },
  test: {
    include: ["features/**/*.steps.ts"],
    server: { deps: { external: [/^bun:/] } },
    reporters: ci ? ["default", "junit"] : ["default"],
    outputFile: ci ? { junit: "reports/bdd-junit.xml" } : undefined,
    testTimeout: 20000,
  },
});

How the code works

One World per feature file. The steps file reads the folder, and for each .feature it builds a fresh World. BeforeAllScenarios runs the migrations once for that file; BeforeEachScenario creates a new user. So the database is isolated per file, and each scenario is isolated by user, not by database.

ts
for (const file of readdirSync("features/mcp").filter((f) => f.endsWith(".feature")).sort()) {
  const feature = await loadFeature(`features/mcp/${file}`);
  describeFeature(feature, ({ Scenario, defineSteps, BeforeAllScenarios, BeforeEachScenario }) => {
    const w = new World();
    BeforeAllScenarios(async () => { await w.init(); });
    BeforeEachScenario(async () => { await w.newUser(); });

    defineSteps((K: any) => {
      const all = (pat: string, fn: any) => { for (const k of ["Given", "When", "Then", "And", "But"]) K[k](pat, fn); };
      const Given = all, When = all, Then = all;
      // ...every step sentence...
    });
    for (const s of (feature as any).scenarii) Scenario(s.description, () => {});
  });
}

One sentence, every keyword. vitest-cucumber wants steps under the keyword they're used with. The all helper registers each sentence under Given, When, Then, And and But at once, so "Given/When/Then" in the steps file is only for reading. The empty Scenario(...) loop at the end just tells the library every scenario is handled by the shared steps.

The World. :memory: bun:sqlite, same Drizzle migrations as production, the exercise library seeded. No live data. Tokens are a plain map: mint(scope) makes a random token for the current user with a scope. Every new user starts with a read-write one.

ts
export class World {
  raw = new Database(":memory:");
  db: any = drizzle(this.raw, { schema: S });
  tokens = new Map<string, { userId: string; tokenId: string; scope: string }>();
  async init() {
    migrate(this.db, { migrationsFolder: "drizzle" });
    // + seed the shared exercise library
  }
  async newUser() {
    this.userId = uid("u_"); this.vars = {}; this.res = null; this.rpc = null;
    await this.db.insert(S.users).values({ id: this.userId, email: `${this.userId}@bdd.test`, passwordHash: "x" });
    await this.db.insert(S.profiles).values({ userId: this.userId });
    this.token = this.mint("rackd2:read rackd2:write");
  }
  mint(scope: string) { const t = uid("rk2_"); this.tokens.set(t, { userId: this.userId, tokenId: uid("tk_"), scope }); return t; }

Real JSON-RPC, no network. call wraps a tools/call request and hands a real Request to the same mcpPost handler production uses. Bearer parsing, scope checks and JSON-RPC errors all run. Only the token lookup and analytics are stubbed in deps(). It keeps the structured result in w.res and remembers ids ($session, $clarification…) so fill can drop them, and $today+1-style dates, into the next JSON docstring.

ts
  async send(method: string, params: unknown = {}) {
    const headers: Record<string, string> = { "content-type": "application/json" };
    if (this.token) headers.authorization = `Bearer ${this.token}`;
    const r = await mcpPost(this.deps(), new Request("http://bdd.test/mcp", { method: "POST", headers, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }) }));
    this.status = r.status; const txt = await r.text(); this.rpc = txt ? JSON.parse(txt) : null;
    this.res = this.rpc?.result?.structuredContent ?? this.rpc?.result ?? this.rpc?.error ?? null;
    this.remember(this.res); return this.res;
  }
  call(name: string, args: Record<string, unknown> = {}) { return this.send("tools/call", { name, arguments: args }); }

Given / When / Then. Given steps set up the situation through the same MCP tools (a past session is create_session, start_session, log_sets, end_session), or swap the token for a narrower one. When steps make the call. Then steps are expect on the response.

ts
Given("my AI is connected with a read-only token", () => { w.token = w.mint("rackd2:read"); });
Given("I weigh {int} kg", async (_c, kg: number) => { await w.call("update_profile", { bodyweight_kg: kg }); await w.answer(1); });
When("my AI calls {string} with:", async (_c, t: string, doc: string) => { await w.call(t, json(w, doc)); });
When("I answer {string} with {string}", async (_c, q: string, v: string) => { await w.call("answer_clarification", { clarification_id: w.vars.clarification, answers: { [q]: v } }); });
Then("my AI is asked to clarify", () => { expect(w.res?.status, JSON.stringify(w.res)).toBe("needs_clarification"); });
Then("the call is refused mentioning {string}", (_c, s: string) => { const txt = JSON.stringify(w.rpc).toLowerCase(); expect(w.rpc?.error || w.rpc?.result?.isError || w.status >= 400, txt).toBeTruthy(); expect(txt).toContain(s.toLowerCase()); });
gherkin
  Scenario: Spotting a stalled lift
    Given I did a session 15 days ago with "bench 100kg x5,5,5"
    And I did a session 10 days ago with "bench 100kg x5,5,5"
    And I did a session 5 days ago with "bench 100kg x5,5,4"
    When my AI calls "detect_stalls"
    Then the call succeeds
    And the table shows "Bench Press"

needs_clarification

The tools are told never to guess. An ambiguous exercise, a duplicate, or a 10% bodyweight jump returns needs_clarification with questions. The AI asks me and calls answer_clarification. In specs I answer explicitly, or w.answer(n) picks option n for every open question until the tool completes. Given steps use it so setup never stalls on a question.

gherkin
  Scenario: Adding an exercise that is already in the session asks first
    Given I created a session with "bench press"
    When my AI calls "add_session_exercises" with:
      """
      {"session_id": "$session", "exercises": [{"exercise": "bench press"}]}
      """
    Then my AI is asked a question mentioning "already"
    When I answer "dup:Bench Press" with "skip"
    Then the session lists exercises "bench press"
ts
  /** Answer every open question with option n (1-based) until the tool completes. */
  async answer(n = 1, rounds = 4) {
    for (let i = 0; i < rounds && this.res?.status === "needs_clarification"; i++) {
      const answers: Record<string, string> = {}; for (const q of this.res.questions) answers[q.id] = q.options?.[Math.min(n, q.options.length) - 1]?.value ?? "ok";
      await this.call("answer_clarification", { clarification_id: this.res.clarification_id, answers });
    }
    return this.res;
  }

Result

bash
$ bun run test:bdd      # bunx --bun vitest run -c vitest.bdd.config.ts

 ✓ features/mcp.steps.ts (424 tests) 1923ms

 Test Files  1 passed (1)
      Tests  424 passed (424)
   Duration  2.47s

Vitest counts steps, not scenarios: 89 scenarios, 424 steps, all green in about two seconds. That speed is the point. The agent runs it after every change.

Changing a feature

Same loop, smaller. I edit or add the scenario first. That one goes red; the other 88 stay green. The LLM changes the code until it's green too. If something else turns red on the way, that's the regression net doing its job. Deleting a scenario is how a behaviour goes away. The .feature files stay the living spec: what's in them is what the tools do.

The rule: the LLM never edits a .feature file to make it pass. Only I edit specs. It changes code.

Example (hypothetical change) — cap warm-ups at 3 sets

Before, in presentation_calendar.feature:

gherkin
    Then "warmup_detail" is not set
    And at most 4 warm-up sets are planned

After, one number changed by me:

gherkin
    Then "warmup_detail" is not set
    And at most 3 warm-up sets are planned

The step already exists, so no new step code. The scenario goes red, the LLM edits the warm-up planner, everything goes green.

The specs are portable

Nothing in the .feature files is TypeScript. They're plain Gherkin, the same format Cucumber uses, so the same specs run unchanged in other languages. Only the step functions get rewritten. Here it's@amiceli/vitest-cucumber; in the browser it could be playwright-bdd; in Python, pytest-bdd orbehave (withbehave-django); in .NET, Reqnroll.

Illustrative example, not Rackd2 code — one step in Django with pytest-bdd

from pytest_bdd import given, parsers

@given(parsers.parse("I weigh {kg:d} kg"))
def set_weight(user, kg):
    user.profile.bodyweight_kg = kg
    user.profile.save()

Same sentence, same scenario, different glue. In Django the test database plays the role of the isolated DB: each test gets a clean one and nothing leaks between scenarios.

What it doesn't test

  • The LLM's choices. Whether Grok picks quick_log or log_sets, reads the clarification back, or invents a weight. That needs evals with real models, not specs.
  • OAuth. Tokens are minted in a map; the real connect flow is tested elsewhere.
  • Cloudflare. SQLite in memory, not the deployed database or the Worker.
  • Tool descriptions. A spec can pass while a description still confuses a model.

So: specs for the contract, evals for the model. The specs are the cheap half, which is why they come first.

Part 2: Using BDD to add Passkey authentication to rackd2.

Try it at rackd2.davidbroza.dev · tool reference at /docs/mcp