236 lines
8.4 KiB
TypeScript
236 lines
8.4 KiB
TypeScript
/**
|
|
* Pricing tools: get_pricing, compare_prices, get_competitor_stock
|
|
*/
|
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { z } from "zod";
|
|
import { pool } from "../db.js";
|
|
|
|
export async function registerPricingTools(server: McpServer): Promise<void> {
|
|
// --- Tool: get_pricing ---
|
|
server.tool(
|
|
"get_pricing",
|
|
"Get current prices and availability across all sources for a specific transceiver. Includes price history if requested.",
|
|
{
|
|
part_number: z.string().describe("Part number or slug, e.g. 'qsfp28-lr4' or 'SFP-10G-LR'"),
|
|
vendor: z.string().optional().describe("Filter to specific vendor only"),
|
|
include_history: z.boolean().default(false).describe("Include 30-day price history"),
|
|
},
|
|
async ({ part_number, vendor, include_history }) => {
|
|
// Find transceiver
|
|
const txResult = await pool.query(
|
|
`SELECT t.id, t.slug, t.standard_name, t.form_factor, t.speed, t.reach_label
|
|
FROM transceivers t
|
|
WHERE t.slug ILIKE $1 OR t.part_number ILIKE $1 OR t.standard_name ILIKE $1
|
|
LIMIT 1`,
|
|
[`%${part_number}%`]
|
|
);
|
|
|
|
if (txResult.rows.length === 0) {
|
|
return {
|
|
content: [{ type: "text", text: `No transceiver found for "${part_number}".` }],
|
|
};
|
|
}
|
|
|
|
const tx = txResult.rows[0];
|
|
const vendorCondition = vendor ? `AND v.name ILIKE $2` : "";
|
|
const baseParams: unknown[] = vendor ? [tx.id, `%${vendor}%`] : [tx.id];
|
|
|
|
// Current prices (latest per vendor)
|
|
const currentPrices = await pool.query(
|
|
`SELECT DISTINCT ON (po.source_vendor_id)
|
|
v.name as vendor, po.price, po.currency, po.stock_level,
|
|
po.quantity_available, po.url, po.time
|
|
FROM price_observations po
|
|
JOIN vendors v ON v.id = po.source_vendor_id
|
|
WHERE po.transceiver_id = $1 ${vendorCondition}
|
|
ORDER BY po.source_vendor_id, po.time DESC`,
|
|
baseParams
|
|
);
|
|
|
|
let history = null;
|
|
if (include_history) {
|
|
const histResult = await pool.query(
|
|
`SELECT v.name as vendor, po.price, po.currency, po.stock_level, po.time
|
|
FROM price_observations po
|
|
JOIN vendors v ON v.id = po.source_vendor_id
|
|
WHERE po.transceiver_id = $1 ${vendorCondition}
|
|
AND po.time > NOW() - INTERVAL '30 days'
|
|
ORDER BY po.time DESC
|
|
LIMIT 200`,
|
|
baseParams
|
|
);
|
|
history = histResult.rows;
|
|
}
|
|
|
|
const response = {
|
|
transceiver: {
|
|
slug: tx.slug,
|
|
standard: tx.standard_name,
|
|
form_factor: tx.form_factor,
|
|
speed: tx.speed,
|
|
reach: tx.reach_label,
|
|
},
|
|
current_prices: currentPrices.rows,
|
|
cheapest: currentPrices.rows.length > 0
|
|
? currentPrices.rows.reduce((min, r) => r.price < min.price ? r : min)
|
|
: null,
|
|
history: include_history ? history : undefined,
|
|
};
|
|
|
|
return {
|
|
content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
|
|
};
|
|
}
|
|
);
|
|
|
|
// --- Tool: compare_prices ---
|
|
server.tool(
|
|
"compare_prices",
|
|
"Compare prices across all vendors for a transceiver. Shows savings opportunities vs competitors.",
|
|
{
|
|
transceiver_query: z.string().describe("Free text or part number to search for"),
|
|
competitors: z.array(z.string()).optional().describe("Limit to specific competitors"),
|
|
},
|
|
async ({ transceiver_query, competitors }) => {
|
|
const vendorFilter = competitors && competitors.length > 0
|
|
? `AND v.name ILIKE ANY(ARRAY[${competitors.map((_, i) => `$${i + 2}`).join(",")}])`
|
|
: "";
|
|
|
|
const values: unknown[] = [transceiver_query];
|
|
if (competitors) {
|
|
competitors.forEach((c) => values.push(`%${c}%`));
|
|
}
|
|
|
|
const result = await pool.query(
|
|
`SELECT t.slug, t.standard_name, t.form_factor, t.speed, t.reach_label,
|
|
v.name as vendor, v.type,
|
|
po.price, po.currency, po.stock_level, po.time
|
|
FROM price_observations po
|
|
JOIN transceivers t ON t.id = po.transceiver_id
|
|
JOIN vendors v ON v.id = po.source_vendor_id
|
|
WHERE (t.standard_name ILIKE $1 OR t.slug ILIKE $1)
|
|
${vendorFilter}
|
|
AND po.time = (
|
|
SELECT MAX(time) FROM price_observations
|
|
WHERE transceiver_id = t.id AND source_vendor_id = v.id
|
|
)
|
|
ORDER BY t.slug, po.price ASC`,
|
|
values
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return {
|
|
content: [{ type: "text", text: `No pricing data found for "${transceiver_query}".` }],
|
|
};
|
|
}
|
|
|
|
// Group by transceiver
|
|
const grouped: Record<string, {
|
|
transceiver: { slug: string; standard: string; form_factor: string; speed: string; reach: string };
|
|
prices: Array<{ vendor: string; type: string; price: number; currency: string; stock: string }>;
|
|
min_price: number;
|
|
max_price: number;
|
|
savings_vs_max: number;
|
|
}> = {};
|
|
|
|
for (const row of result.rows) {
|
|
if (!grouped[row.slug]) {
|
|
grouped[row.slug] = {
|
|
transceiver: {
|
|
slug: row.slug,
|
|
standard: row.standard_name,
|
|
form_factor: row.form_factor,
|
|
speed: row.speed,
|
|
reach: row.reach_label,
|
|
},
|
|
prices: [],
|
|
min_price: Infinity,
|
|
max_price: 0,
|
|
savings_vs_max: 0,
|
|
};
|
|
}
|
|
grouped[row.slug].prices.push({
|
|
vendor: row.vendor,
|
|
type: row.type,
|
|
price: parseFloat(row.price),
|
|
currency: row.currency,
|
|
stock: row.stock_level,
|
|
});
|
|
grouped[row.slug].min_price = Math.min(grouped[row.slug].min_price, parseFloat(row.price));
|
|
grouped[row.slug].max_price = Math.max(grouped[row.slug].max_price, parseFloat(row.price));
|
|
}
|
|
|
|
for (const slug of Object.keys(grouped)) {
|
|
const g = grouped[slug];
|
|
g.savings_vs_max = Math.round((1 - g.min_price / g.max_price) * 100);
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: JSON.stringify({ comparisons: Object.values(grouped) }, null, 2),
|
|
}],
|
|
};
|
|
}
|
|
);
|
|
|
|
// --- Tool: get_competitor_stock ---
|
|
server.tool(
|
|
"get_competitor_stock",
|
|
"Check live stock levels at a specific competitor. Useful for identifying sales opportunities when competitor is out of stock.",
|
|
{
|
|
competitor: z.string().describe("Competitor name, e.g. 'FS.COM', 'Optcore', 'ProLabs'"),
|
|
product_query: z.string().optional().describe("Optional product filter"),
|
|
out_of_stock_only: z.boolean().default(false).describe("Only show out-of-stock items (sales opportunities)"),
|
|
},
|
|
async ({ competitor, product_query, out_of_stock_only }) => {
|
|
const conditions = [`v.name ILIKE $1`];
|
|
const values: unknown[] = [`%${competitor}%`];
|
|
let idx = 2;
|
|
|
|
if (product_query) {
|
|
conditions.push(`(t.standard_name ILIKE $${idx} OR t.slug ILIKE $${idx})`);
|
|
values.push(`%${product_query}%`);
|
|
idx++;
|
|
}
|
|
|
|
if (out_of_stock_only) {
|
|
conditions.push(`po.stock_level IN ('out_of_stock', 'discontinued')`);
|
|
}
|
|
|
|
const result = await pool.query(
|
|
`SELECT DISTINCT ON (t.id)
|
|
v.name as competitor, t.slug, t.standard_name, t.form_factor, t.speed,
|
|
t.reach_label, po.price, po.currency, po.stock_level,
|
|
po.quantity_available, po.time
|
|
FROM price_observations po
|
|
JOIN vendors v ON v.id = po.source_vendor_id
|
|
JOIN transceivers t ON t.id = po.transceiver_id
|
|
WHERE ${conditions.join(" AND ")}
|
|
ORDER BY t.id, po.time DESC
|
|
LIMIT 100`,
|
|
values
|
|
);
|
|
|
|
const inStock = result.rows.filter((r) => r.stock_level === "in_stock").length;
|
|
const outOfStock = result.rows.filter((r) =>
|
|
["out_of_stock", "discontinued"].includes(r.stock_level)
|
|
).length;
|
|
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: JSON.stringify({
|
|
competitor,
|
|
summary: { total: result.rows.length, in_stock: inStock, out_of_stock: outOfStock },
|
|
items: result.rows,
|
|
sales_opportunities: out_of_stock_only
|
|
? result.rows.length
|
|
: `${outOfStock} items out of stock at ${competitor}`,
|
|
}, null, 2),
|
|
}],
|
|
};
|
|
}
|
|
);
|
|
}
|