Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"typecheck": "tsc --noEmit",
"dev": "tsx src/index.ts",
"dev:http": "tsx src/index.ts --transport http",
"dev:worker": "wrangler dev",
"deploy:worker": "wrangler deploy",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
Expand All @@ -36,12 +38,15 @@
"@libsql/client": "^0.14.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@ohcnetwork/leaderboard-api": "workspace:*",
"agents": "^0.10.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250410.0",
"@types/node": "^20.19.39",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.1.3"
"vitest": "^4.1.3",
"wrangler": "^4.14.0"
}
}
172 changes: 172 additions & 0 deletions packages/mcp-server/src/__tests__/d1-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* Tests for the D1 database client adapter
*/

import { beforeEach, describe, expect, it, vi } from "vitest";
import { D1DatabaseClient, createD1Database } from "../d1-client";

/**
* Create a mock D1Database binding
*/
function createMockD1() {
const mockStatement = {
bind: vi.fn(),
all: vi.fn(),
};

// bind() returns the same statement (chainable)
mockStatement.bind.mockReturnValue(mockStatement);

const d1 = {
prepare: vi.fn().mockReturnValue(mockStatement),
batch: vi.fn(),
} as unknown as D1Database;

return { d1, mockStatement };
}

describe("D1DatabaseClient", () => {
let d1: D1Database;
let mockStatement: ReturnType<typeof createMockD1>["mockStatement"];

beforeEach(() => {
const mock = createMockD1();
d1 = mock.d1;
mockStatement = mock.mockStatement;
});

describe("execute", () => {
it("should execute a query without params", async () => {
mockStatement.all.mockResolvedValue({
results: [{ id: 1, name: "alice" }],
meta: { changes: 0 },
});

const client = new D1DatabaseClient(d1);
const result = await client.execute("SELECT * FROM contributor");

expect(d1.prepare).toHaveBeenCalledWith("SELECT * FROM contributor");
expect(mockStatement.bind).not.toHaveBeenCalled();
expect(result.rows).toEqual([{ id: 1, name: "alice" }]);
expect(result.rowsAffected).toBe(0);
});

it("should execute a query with params", async () => {
mockStatement.all.mockResolvedValue({
results: [{ username: "alice", role: "core" }],
meta: { changes: 0 },
});

const client = new D1DatabaseClient(d1);
const result = await client.execute(
"SELECT * FROM contributor WHERE username = ?",
["alice"],
);

expect(d1.prepare).toHaveBeenCalledWith(
"SELECT * FROM contributor WHERE username = ?",
);
expect(mockStatement.bind).toHaveBeenCalledWith("alice");
expect(result.rows).toEqual([{ username: "alice", role: "core" }]);
});

it("should handle insert results with lastInsertRowid", async () => {
mockStatement.all.mockResolvedValue({
results: [],
meta: { changes: 1, last_row_id: 42 },
});

const client = new D1DatabaseClient(d1);
const result = await client.execute(
"INSERT INTO contributor (username) VALUES (?)",
["bob"],
);

expect(result.rowsAffected).toBe(1);
expect(result.lastInsertRowid).toBe(42n);
});

it("should handle empty results", async () => {
mockStatement.all.mockResolvedValue({
results: [],
meta: { changes: 0 },
});

const client = new D1DatabaseClient(d1);
const result = await client.execute(
"SELECT * FROM contributor WHERE username = ?",
["nonexistent"],
);

expect(result.rows).toEqual([]);
expect(result.rowsAffected).toBe(0);
});

it("should handle undefined results gracefully", async () => {
mockStatement.all.mockResolvedValue({
meta: { changes: 0 },
});

const client = new D1DatabaseClient(d1);
const result = await client.execute("SELECT 1");

expect(result.rows).toEqual([]);
});
});

describe("batch", () => {
it("should execute batch statements", async () => {
const mockStmt1 = {
bind: vi.fn().mockReturnThis(),
all: vi.fn(),
};
const mockStmt2 = {
bind: vi.fn().mockReturnThis(),
all: vi.fn(),
};

(d1.prepare as ReturnType<typeof vi.fn>)
.mockReturnValueOnce(mockStmt1)
.mockReturnValueOnce(mockStmt2);

(d1.batch as ReturnType<typeof vi.fn>).mockResolvedValue([
{ results: [{ count: 5 }], meta: { changes: 0 } },
{ results: [], meta: { changes: 3 } },
]);

const client = new D1DatabaseClient(d1);
const results = await client.batch([
{ sql: "SELECT COUNT(*) as count FROM contributor" },
{
sql: "DELETE FROM activity WHERE contributor = ?",
params: ["alice"],
},
]);

expect(results).toHaveLength(2);
expect(results[0].rows).toEqual([{ count: 5 }]);
expect(results[0].rowsAffected).toBe(0);
expect(results[1].rows).toEqual([]);
expect(results[1].rowsAffected).toBe(3);
});
});

describe("close", () => {
it("should be a no-op", async () => {
const client = new D1DatabaseClient(d1);
await expect(client.close()).resolves.toBeUndefined();
});
});
});

describe("createD1Database", () => {
it("should return a Database instance", () => {
const { d1 } = createMockD1();
const db = createD1Database(d1);

expect(db).toBeInstanceOf(D1DatabaseClient);
expect(db.execute).toBeDefined();
expect(db.batch).toBeDefined();
expect(db.close).toBeDefined();
});
});
60 changes: 60 additions & 0 deletions packages/mcp-server/src/d1-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Cloudflare D1 database client implementing the Database interface
*/

import type {
BatchResult,
BatchStatement,
Database,
ExecuteResult,
} from "@ohcnetwork/leaderboard-api";

/**
* Database client implementation using Cloudflare D1
*/
export class D1DatabaseClient implements Database {
private d1: D1Database;

constructor(d1: D1Database) {
this.d1 = d1;
}

async execute(sql: string, params?: unknown[]): Promise<ExecuteResult> {
const stmt = this.d1.prepare(sql);
const bound = params && params.length > 0 ? stmt.bind(...params) : stmt;
const result = await bound.all();

return {
rows: (result.results ?? []) as Record<string, unknown>[],
rowsAffected: result.meta?.changes ?? 0,
lastInsertRowid: result.meta?.last_row_id
? BigInt(result.meta.last_row_id)
: undefined,
};
}

async batch(statements: BatchStatement[]): Promise<BatchResult[]> {
const d1Statements = statements.map((stmt) => {
const s = this.d1.prepare(stmt.sql);
return stmt.params && stmt.params.length > 0 ? s.bind(...stmt.params) : s;
});

const results = await this.d1.batch(d1Statements);

return results.map((result) => ({
rows: (result.results ?? []) as Record<string, unknown>[],
rowsAffected: result.meta?.changes ?? 0,
}));
}

async close(): Promise<void> {
// D1 connections are managed by the Workers runtime — no-op
}
}

/**
* Create a Database instance backed by Cloudflare D1
*/
export function createD1Database(d1: D1Database): Database {
return new D1DatabaseClient(d1);
}
Loading
Loading