Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@c0upons/cli",
"version": "1.9.2",
"version": "1.9.3",
"description": "c0upons CLI — search, submit, and manage coupons from the terminal",
"type": "module",
"bin": {
Expand Down
10 changes: 4 additions & 6 deletions apps/web/lib/mcp-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ export const SERVER: McpServerInfo = {
'reveal_pending reads a few code-less pages.',
};

const couponFields = 'c.id, c.code, c.title, c.description, c.discount, c.discount_type, c.discount_value, c.expiry_date, c.url, c.votes, c.verified, c.code_checked_at';

export const TOOLS: McpTool[] = [
{
name: 'search_coupons',
Expand All @@ -37,7 +35,7 @@ export const TOOLS: McpTool[] = [
const pattern = `%${String(q).trim()}%`;
const db = getDb();
const rows = await db.sql`
SELECT ${couponFields}, s.name AS store_name, s.slug AS store_slug
SELECT c.id, c.code, c.title, c.description, c.discount, c.discount_type, c.discount_value, c.expiry_date, c.url, c.votes, c.verified, c.code_checked_at, s.name AS store_name, s.slug AS store_slug
FROM coupons c JOIN stores s ON s.id = c.store_id
WHERE c.title LIKE ${pattern} OR c.description LIKE ${pattern} OR s.name LIKE ${pattern} OR c.code LIKE ${pattern}
ORDER BY c.votes DESC LIMIT 50
Expand All @@ -52,7 +50,7 @@ export const TOOLS: McpTool[] = [
async run({ limit }) {
const n = Math.min(100, Math.max(1, Number(limit) || 20));
const rows = await getDb().sql`
SELECT ${couponFields}, s.name AS store_name, s.slug AS store_slug
SELECT c.id, c.code, c.title, c.description, c.discount, c.discount_type, c.discount_value, c.expiry_date, c.url, c.votes, c.verified, c.code_checked_at, s.name AS store_name, s.slug AS store_slug
FROM coupons c JOIN stores s ON s.id = c.store_id
ORDER BY c.votes DESC, c.created_at DESC LIMIT ${n}
`;
Expand Down Expand Up @@ -81,7 +79,7 @@ export const TOOLS: McpTool[] = [
const stores = await db.sql`SELECT id, name, slug, website FROM stores WHERE slug = ${String(slug)}`;
if (!stores.length) throw new Error(`No store with slug "${String(slug)}"`);
const coupons = await db.sql`
SELECT ${couponFields} FROM coupons c WHERE c.store_id = ${stores[0].id}
SELECT c.id, c.code, c.title, c.description, c.discount, c.discount_type, c.discount_value, c.expiry_date, c.url, c.votes, c.verified, c.code_checked_at FROM coupons c WHERE c.store_id = ${stores[0].id}
ORDER BY c.votes DESC, c.created_at DESC
`;
return { store: stores[0], count: coupons.length, coupons };
Expand All @@ -93,7 +91,7 @@ export const TOOLS: McpTool[] = [
inputSchema: { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'], additionalProperties: false },
async run({ id }) {
const rows = await getDb().sql`
SELECT ${couponFields}, c.code_source, s.name AS store_name, s.slug AS store_slug, s.website AS store_website
SELECT c.id, c.code, c.title, c.description, c.discount, c.discount_type, c.discount_value, c.expiry_date, c.url, c.votes, c.verified, c.code_checked_at, c.code_source, s.name AS store_name, s.slug AS store_slug, s.website AS store_website
FROM coupons c JOIN stores s ON s.id = c.store_id WHERE c.id = ${Number(id)}
`;
if (!rows.length) throw new Error(`No coupon ${String(id)}`);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@c0upons/web",
"version": "1.9.2",
"version": "1.9.3",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/public/cli/c0upons
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

set -euo pipefail

VERSION="1.9.2"
VERSION="1.9.3"
BASE_URL="${C0UPONS_API:-https://c0upons.com/api}"
# Website root (for login + share links). Defaults to BASE_URL without /api.
WEB_URL="${C0UPONS_WEB:-${BASE_URL%/api}}"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "c0upons",
"version": "1.9.2",
"version": "1.9.3",
"private": true,
"type": "module",
"packageManager": "bun@1.4.0",
Expand Down
73 changes: 73 additions & 0 deletions test/mcp-tools.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// The MCP coupon tools against the real migration in a local libSQL file.
// db.sql binds every ${} as a parameter, so a column list interpolated into
// a SELECT comes back as one string column instead of the coupon; this
// proves each tool returns the coupon's own fields.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mock } from 'bun:test';

// db.ts and mcp-tools.ts open with `import 'server-only'`, which only Next resolves.
mock.module('server-only', () => ({}));

const dir = mkdtempSync(join(tmpdir(), 'c0upons-mcp-tools-'));
const url = `file:${join(dir, 'local.db')}`;
execFileSync('node', ['apps/web/scripts/migrate.mjs'], {
cwd: new URL('..', import.meta.url),
env: { ...process.env, TURSO_DATABASE_URL: url, TURSO_AUTH_TOKEN: '' },
stdio: 'pipe',
});
const { getDb } = await import('../apps/web/lib/db.ts');
const { TOOLS } = await import('../apps/web/lib/mcp-tools.ts');
const db = getDb();
const tool = (name) => TOOLS.find((t) => t.name === name);
let couponId;

// db.ts opens its client from DATABASE_URL on the first query. Unset it right
// after: bun runs every test file in one process, and the other files'
// migrate.mjs children would inherit it and prefer it over their own database.
before(async () => {
const previousUrl = process.env.DATABASE_URL;
process.env.DATABASE_URL = url;
await db.sql`INSERT INTO stores (name, slug) VALUES ('Nike', 'nike')`;
const [store] = await db.sql`SELECT id FROM stores WHERE slug = 'nike'`;
await db.sql`INSERT INTO coupons (store_id, code, title, votes) VALUES (${store.id}, 'SAVE20', '20% off shoes', 5)`;
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
[{ id: couponId }] = await db.sql`SELECT id FROM coupons`;
if (previousUrl === undefined) delete process.env.DATABASE_URL;
else process.env.DATABASE_URL = previousUrl;
});

after(() => rmSync(dir, { recursive: true, force: true }));

function assertCoupon(c) {
assert.equal(c['?column?'], undefined);
assert.equal(c.code, 'SAVE20');
assert.equal(c.title, '20% off shoes');
assert.equal(Number(c.votes), 5);
}

test('search_coupons returns the coupon fields', async () => {
const r = await tool('search_coupons').run({ q: 'shoes' });
assert.equal(r.count, 1);
assertCoupon(r.coupons[0]);
assert.equal(r.coupons[0].store_slug, 'nike');
});

test('top_coupons returns the coupon fields', async () => {
const r = await tool('top_coupons').run({ limit: 5 });
assertCoupon(r.coupons[0]);
});

test('store_coupons returns the coupon fields', async () => {
const r = await tool('store_coupons').run({ slug: 'nike' });
assertCoupon(r.coupons[0]);
});

test('get_coupon returns the coupon fields', async () => {
const r = await tool('get_coupon').run({ id: couponId });
assertCoupon(r);
assert.equal(r.store_name, 'Nike');
});
Loading