Skip to content
Open
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
101 changes: 101 additions & 0 deletions mcp/tools/__tests__/read-tools.contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from "@jest/globals";
import { buildPricingBlock, parseProductEvent } from "@/mcp/tools/read-tools";
import { NostrEvent } from "@/utils/types/types";

function makeProductEvent(overrides?: Partial<NostrEvent>): NostrEvent {
return {
id: "product-1",
pubkey: "seller-pubkey",
created_at: 1710000000,
kind: 30402,
content: "",
sig: "sig",
tags: [
["d", "tea-001"],
["title", "Green Tea"],
["summary", "Single origin"],
["image", "https://example.com/tea.jpg"],
["t", "beverages"],
["location", "Online"],
["price", "100", "USD"],
["shipping", "Added Cost", "5", "USD"],
["required_customer_info", "email"],
["size", "M", "8"],
["volume", "250g", "140"],
["weight", "1 lb", "220"],
["bulk", "10", "900"],
["subscription", "true"],
["subscription_discount", "10"],
["subscription_frequency", "weekly", "monthly"],
["published_at", "1700000000"],
["valid_until", "1900000000"],
["content-warning"],
["restrictions", "US only"],
],
...overrides,
};
}

describe("read-tools MCP product contract helpers", () => {
it("parseProductEvent returns MCP-safe product shape with pricing and subscription", () => {
const parsed = parseProductEvent(makeProductEvent());

expect(parsed).toEqual(
expect.objectContaining({
id: "product-1",
pubkey: "seller-pubkey",
d: "tea-001",
title: "Green Tea",
summary: "Single origin",
requiredCustomerInfo: "email",
publishedAt: "1700000000",
validUntil: 1900000000,
contentWarning: true,
})
);

expect(parsed.pricing).toEqual({
amount: 100,
currency: "USD",
unit: "per item",
shippingCost: 5,
shippingType: "Added Cost",
totalEstimate: 105,
paymentMethods: ["lightning", "cashu"],
});

expect(parsed.subscription).toEqual({
enabled: true,
discount: 10,
frequencies: ["weekly", "monthly"],
});
});

it("parseProductEvent maps legacy UI-only 'required' to MCP requiredCustomerInfo", () => {
const parsed = parseProductEvent(
makeProductEvent({
tags: [
["title", "Legacy Product"],
["price", "25", "USD"],
["required", "email"],
],
})
);

expect(parsed.requiredCustomerInfo).toBe("email");
});

it("buildPricingBlock keeps free shipping total estimate deterministic", () => {
const pricing = buildPricingBlock(120, "USD", "Free", 999, 2);

expect(pricing).toEqual({
amount: 120,
currency: "USD",
unit: "per item",
shippingCost: 0,
shippingType: "Free",
totalEstimate: 240,
paymentMethods: ["lightning", "cashu"],
});
});
});
88 changes: 14 additions & 74 deletions mcp/tools/read-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@ import {
validateDiscountCode,
getDbPool,
} from "@/utils/db/db-service";
import {
getEffectiveShippingCost,
parseShippingFromTags,
} from "@/utils/parsers/product-tag-helpers";
import { getEffectiveShippingCost } from "@/utils/parsers/product-tag-helpers";
import { parseCanonicalProductEvent } from "@/utils/parsers/product-event/base-parser";
import { toMcpProductData } from "@/utils/parsers/product-event/mcp-adapter";
import { NostrEvent } from "@/utils/types/types";
import { registerTool } from "./register-tool";

Expand All @@ -19,13 +18,6 @@ function getTagValue(tags: string[][], key: string): string | undefined {
return tag ? tag[1] : undefined;
}

function getAllTagValues(tags: string[][], key: string): string[] {
return tags
.filter((t) => t[0] === key)
.map((t) => t[1]!)
.filter(Boolean);
}

function determinePaymentMethods(
_sellerPubkey?: string,
hasStripeConnect?: boolean
Expand All @@ -37,7 +29,7 @@ function determinePaymentMethods(
return methods;
}

function buildPricingBlock(
export function buildPricingBlock(
price: number,
currency: string,
shippingType?: string,
Expand All @@ -61,70 +53,18 @@ function buildPricingBlock(
};
}

function parseProductEvent(event: NostrEvent) {
const tags = event.tags || [];
const priceTag = tags.find((t) => t[0] === "price");
const parsedShipping = parseShippingFromTags(tags);

const price = priceTag ? Number(priceTag[1]) : 0;
const currency = priceTag ? priceTag[2] || "" : "";
const shippingType = parsedShipping?.shippingType;
const shippingCost = parsedShipping?.shippingCost;

const sizes = tags
.filter((t) => t[0] === "size" && t[1])
.map((t) => ({ size: t[1]!, quantity: t[2] ? Number(t[2]) : undefined }));

const volumes = tags
.filter((t) => t[0] === "volume" && t[1])
.map((t) => ({ volume: t[1]!, price: t[2] ? Number(t[2]) : undefined }));

const weights = tags
.filter((t) => t[0] === "weight" && t[1])
.map((t) => ({ weight: t[1]!, price: t[2] ? Number(t[2]) : undefined }));

const bulk = tags
.filter((t) => t[0] === "bulk" && t[1] && t[2])
.map((t) => ({ units: Number(t[1]), price: Number(t[2]) }));

const pickupLocations = getAllTagValues(tags, "pickup_location");
export function parseProductEvent(event: NostrEvent) {
const canonical = parseCanonicalProductEvent(event);
const parsed = toMcpProductData(canonical);

return {
id: event.id,
pubkey: event.pubkey,
d: getTagValue(tags, "d"),
title: getTagValue(tags, "title") || "",
summary: getTagValue(tags, "summary") || "",
images: getAllTagValues(tags, "image"),
categories: getAllTagValues(tags, "t"),
location: getTagValue(tags, "location") || "",
price,
currency,
shippingType,
shippingCost,
quantity: getTagValue(tags, "quantity")
? Number(getTagValue(tags, "quantity"))
: undefined,
condition: getTagValue(tags, "condition"),
status: getTagValue(tags, "status"),
sizes: sizes.length > 0 ? sizes : undefined,
volumes: volumes.length > 0 ? volumes : undefined,
weights: weights.length > 0 ? weights : undefined,
bulk: bulk.length > 0 ? bulk : undefined,
pickupLocations: pickupLocations.length > 0 ? pickupLocations : undefined,
requiredCustomerInfo: getTagValue(tags, "required_customer_info"),
createdAt: event.created_at,
pricing: buildPricingBlock(price, currency, shippingType, shippingCost),
subscription: {
enabled: getTagValue(tags, "subscription") === "true",
discount: getTagValue(tags, "subscription_discount")
? Number(getTagValue(tags, "subscription_discount"))
: undefined,
frequencies: (() => {
const freqTag = tags.find((t) => t[0] === "subscription_frequency");
return freqTag ? freqTag.slice(1) : [];
})(),
},
...parsed,
pricing: buildPricingBlock(
parsed.price,
parsed.currency,
parsed.shippingType,
parsed.shippingCost
),
};
}

Expand Down
81 changes: 81 additions & 0 deletions utils/parsers/__tests__/product-event-parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { parseCanonicalProductEvent } from "@/utils/parsers/product-event/base-parser";
import { toMcpProductData } from "@/utils/parsers/product-event/mcp-adapter";
import { NostrEvent } from "@/utils/types/types";

describe("product canonical parser + adapters", () => {
const baseEvent: NostrEvent = {
id: "product-1",
pubkey: "seller-pubkey",
created_at: 1710000000,
kind: 30402,
content: "",
sig: "sig",
tags: [],
};

it("parses shared tags once and exposes alias fields", () => {
const event: NostrEvent = {
...baseEvent,
tags: [
["title", "Tea"],
["summary", "Organic green tea"],
["price", "12", "USD"],
["required_customer_info", "Phone number"],
["restrictions", "US only"],
],
};

const canonical = parseCanonicalProductEvent(event);

expect(canonical.title).toBe("Tea");
expect(canonical.summary).toBe("Organic green tea");
expect(canonical.price).toBe(12);
expect(canonical.currency).toBe("USD");
expect(canonical.requiredCustomerInfo).toBe("Phone number");
expect(canonical.required).toBe("Phone number");
expect(canonical.restrictions).toBe("US only");
});

it("uses last valid shipping tag", () => {
const event: NostrEvent = {
...baseEvent,
tags: [
["shipping", "5", "USD"],
["shipping", "Added Cost", "7", "USD"],
],
};

const canonical = parseCanonicalProductEvent(event);

expect(canonical.shippingType).toBe("Added Cost");
expect(canonical.shippingCost).toBe(7);
});

it("builds MCP-safe variant and subscription blocks", () => {
const event: NostrEvent = {
...baseEvent,
tags: [
["size", "M", "5"],
["volume", "250g", "18"],
["weight", "1 lb", "30"],
["bulk", "10", "99"],
["subscription", "true"],
["subscription_discount", "15"],
["subscription_frequency", "weekly", "monthly"],
],
};

const canonical = parseCanonicalProductEvent(event);
const mcpProduct = toMcpProductData(canonical);

expect(mcpProduct.sizes).toEqual([{ size: "M", quantity: 5 }]);
expect(mcpProduct.volumes).toEqual([{ volume: "250g", price: 18 }]);
expect(mcpProduct.weights).toEqual([{ weight: "1 lb", price: 30 }]);
expect(mcpProduct.bulk).toEqual([{ units: 10, price: 99 }]);
expect(mcpProduct.subscription).toEqual({
enabled: true,
discount: 15,
frequencies: ["weekly", "monthly"],
});
});
});
10 changes: 10 additions & 0 deletions utils/parsers/__tests__/product-parser-functions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,4 +277,14 @@ describe("parseTags", () => {

expect(result.contentWarning).toBeFalsy();
});

it("should map required_customer_info to required for UI consumers", () => {
const event = {
...baseEvent,
tags: [["required_customer_info", "Phone number"]],
};
const result = parseTags(event)!;

expect(result.required).toBe("Phone number");
});
});
Loading