Rene Fichtmueller e9fcda2811 feat: wire finder.ts + switch-docs + Ollama LLM tools to MCP server
MCP Server (packages/mcp-server/src/index.ts):
- Register registerSwitchDocTools (switch-docs.ts) — switch documentation lookup
- Register finderTools dynamically (finder.ts) — find_flexoptix_for_switch, get_competitor_alerts
- Add analyze_market_with_llm tool: qwen2.5:14b via Ollama, enriched with live hype cycle + pricing + news
- Add generate_blog_post tool: fo-blog-v5 (fine-tuned) with qwen2.5:14b fallback, enriched with live pricing data
- OLLAMA_BASE_URL env var (default: https://ollama.fichtmueller.org)

Also includes scraper improvements (ascentoptics, atgbics, gbics, skylane, ebay-enricher),
API route updates (blog, blog-sll, health, hot-topics, transceivers, queries),
and dashboard hot-topics refresh.
2026-04-18 00:21:58 +02:00

546 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* TIP MCP Server — Transceiver Intelligence Platform
*
* 15 Tools for LLM access to transceiver data, pricing, compatibility,
* hype cycle, knowledge base, news, market intelligence, and blog generation.
*
* Transport: stdio (for Claude Code, EO Global Pulse, etc.)
*
* Usage:
* tsx src/index.ts — Run MCP server via stdio
* npx @tip/mcp-server — After npm install -g
*
* Claude Code config (~/.claude/mcp.json):
* { "tip": { "command": "npx", "args": ["@tip/mcp-server"] } }
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { pool } from "./db.js";
import { registerPricingTools } from "./tools/pricing.js";
import { registerCompatibilityTools } from "./tools/compatibility.js";
import { registerKnowledgeTools } from "./tools/knowledge.js";
import { registerContentTools } from "./tools/content.js";
import { registerMarketTools } from "./tools/market.js";
import { registerSwitchDocTools } from "./tools/switch-docs.js";
import { finderTools, handleFinderTool } from "./tools/finder.js";
async function main() {
const server = new McpServer({
name: "tip-mcp-server",
version: "0.2.0",
});
// --- Tool: search_transceivers ---
server.tool(
"search_transceivers",
"Search transceivers by free text, specs, or compatibility. Returns matching transceivers with current pricing if available.",
{
query: z.string().optional().describe("Free text query, e.g. '10km for Cisco Nexus' or '400G QSFP-DD ZR'"),
form_factor: z.string().optional().describe("SFP, SFP+, SFP28, QSFP+, QSFP28, QSFP-DD, OSFP, CFP2, etc."),
speed_gbps: z.number().optional().describe("Speed in Gbps: 1, 10, 25, 40, 100, 200, 400, 800"),
reach_label: z.string().optional().describe("SR, LR, ER, ZR, or distance like 10km, 80km"),
fiber_type: z.enum(["SMF", "MMF"]).optional().describe("Single-mode or Multi-mode fiber"),
wdm_type: z.enum(["CWDM", "DWDM"]).optional().describe("Wavelength division multiplexing type"),
vendor: z.string().optional().describe("Vendor/manufacturer filter, e.g. 'Cisco', 'Juniper', 'FS.COM', 'Flexoptix'"),
category: z.string().optional().describe("Category filter: DataCenter, AOC, DAC, DWDM, CWDM, Coherent, Metro, LongHaul, etc."),
market_status: z.enum(["Mainstream", "Growth", "Emerging", "Legacy", "EOL"]).optional().describe("Market status filter"),
max_results: z.number().default(10).describe("Maximum results to return"),
},
async ({ query, form_factor, speed_gbps, reach_label, fiber_type, wdm_type, vendor, category, market_status, max_results }) => {
const conditions: string[] = [];
const values: unknown[] = [];
let idx = 1;
if (query) {
conditions.push(`t.search_vector @@ plainto_tsquery('english', $${idx})`);
values.push(query);
idx++;
}
if (form_factor) {
conditions.push(`t.form_factor ILIKE $${idx}`);
values.push(`%${form_factor}%`);
idx++;
}
if (speed_gbps) {
conditions.push(`t.speed_gbps = $${idx}`);
values.push(speed_gbps);
idx++;
}
if (reach_label) {
conditions.push(`(t.reach_label ILIKE $${idx} OR t.standard_name ILIKE $${idx})`);
values.push(`%${reach_label}%`);
idx++;
}
if (fiber_type) {
conditions.push(`t.fiber_type = $${idx}`);
values.push(fiber_type);
idx++;
}
if (wdm_type) {
conditions.push(`t.wdm_type = $${idx}`);
values.push(wdm_type);
idx++;
}
if (vendor) {
conditions.push(`v.name ILIKE $${idx}`);
values.push(`%${vendor}%`);
idx++;
}
if (category) {
conditions.push(`t.category ILIKE $${idx}`);
values.push(`%${category}%`);
idx++;
}
if (market_status) {
conditions.push(`t.market_status = $${idx}`);
values.push(market_status);
idx++;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const orderBy = query
? `ORDER BY ts_rank(t.search_vector, plainto_tsquery('english', $1)) DESC`
: "ORDER BY t.speed_gbps DESC, t.reach_meters ASC";
values.push(max_results);
const result = await pool.query(
`SELECT t.id, t.slug, t.standard_name, t.form_factor, t.speed, t.speed_gbps,
t.reach_label, t.reach_meters, t.fiber_type, t.connector, t.wdm_type,
t.wavelengths, t.power_consumption_w, t.temp_range, t.category,
t.market_status, t.hype_cycle_phase,
v.name as vendor_name,
(SELECT jsonb_agg(jsonb_build_object(
'vendor', sv.name, 'price', po.price, 'currency', po.currency,
'stock', po.stock_level, 'url', po.url
) ORDER BY po.time DESC)
FROM price_observations po
JOIN vendors sv ON sv.id = po.source_vendor_id
WHERE po.transceiver_id = t.id
AND po.time > NOW() - INTERVAL '7 days'
) as pricing
FROM transceivers t
LEFT JOIN vendors v ON v.id = t.vendor_id
${where}
${orderBy}
LIMIT $${idx}`,
values
);
if (result.rows.length === 0) {
return {
content: [{ type: "text", text: "No transceivers found matching your criteria." }],
};
}
const formatted = result.rows.map((r) => ({
slug: r.slug,
standard: r.standard_name,
form_factor: r.form_factor,
speed: r.speed,
speed_gbps: r.speed_gbps,
reach: r.reach_label,
fiber: r.fiber_type,
connector: r.connector,
wdm: r.wdm_type,
wavelengths: r.wavelengths,
power_w: r.power_consumption_w,
temp: r.temp_range,
category: r.category,
market_status: r.market_status,
vendor: r.vendor_name,
pricing: r.pricing || [],
}));
return {
content: [{
type: "text",
text: JSON.stringify({ count: result.rows.length, transceivers: formatted }, null, 2),
}],
};
}
);
// --- Tool: check_compatibility ---
server.tool(
"check_compatibility",
"Check compatibility between a switch model and transceivers. Returns verified compatible transceivers with firmware requirements. When no exact match is found, suggests alternative transceivers that may work.",
{
switch_model: z.string().describe("Switch model, e.g. 'Cisco Nexus 93180YC-FX3' or 'Juniper EX4300'"),
transceiver_query: z.string().optional().describe("Optional: filter by transceiver type or part number"),
speed_gbps: z.number().optional().describe("Optional: filter by speed"),
reach: z.string().optional().describe("Optional: filter by reach (SR, LR, etc.)"),
},
async ({ switch_model, transceiver_query, speed_gbps, reach }) => {
// First: find the switch
const switchResult = await pool.query(
`SELECT s.id, s.model, s.series, s.max_speed_gbps, s.ports_config,
v.name as vendor
FROM switches s
JOIN vendors v ON v.id = s.vendor_id
WHERE s.model ILIKE $1 OR s.series ILIKE $1
LIMIT 5`,
[`%${switch_model}%`]
);
if (switchResult.rows.length === 0) {
// Suggest similar switches using trigram similarity
const similarResult = await pool.query(
`SELECT s.model, s.series, v.name as vendor,
similarity(s.model, $1) as sim
FROM switches s
JOIN vendors v ON v.id = s.vendor_id
WHERE similarity(s.model, $1) > 0.1
ORDER BY sim DESC
LIMIT 5`,
[switch_model]
);
const suggestions = similarResult.rows.length > 0
? `\n\nDid you mean one of these?\n${similarResult.rows.map(r => ` - ${r.vendor} ${r.model} (${r.series})`).join("\n")}`
: "";
return {
content: [{
type: "text",
text: `No switch found matching "${switch_model}". Try a shorter model name or check spelling.${suggestions}`,
}],
};
}
const sw = switchResult.rows[0];
const conditions = [`c.switch_id = $1`];
const values: unknown[] = [sw.id];
let idx = 2;
if (transceiver_query) {
conditions.push(`(t.standard_name ILIKE $${idx} OR t.slug ILIKE $${idx})`);
values.push(`%${transceiver_query}%`);
idx++;
}
if (speed_gbps) {
conditions.push(`t.speed_gbps = $${idx}`);
values.push(speed_gbps);
idx++;
}
if (reach) {
conditions.push(`t.reach_label ILIKE $${idx}`);
values.push(`%${reach}%`);
idx++;
}
const compatResult = await pool.query(
`SELECT t.id, t.slug, t.standard_name, t.form_factor, t.speed, t.speed_gbps,
t.reach_label, t.reach_meters, t.fiber_type,
c.status, c.firmware_min, c.verified_by, c.verification_method, c.notes as compat_notes,
(SELECT MIN(po.price) FROM price_observations po
WHERE po.transceiver_id = t.id AND po.time > NOW() - INTERVAL '7 days'
) as min_price
FROM compatibility c
JOIN transceivers t ON t.id = c.transceiver_id
WHERE ${conditions.join(" AND ")}
AND c.status = 'compatible'
ORDER BY t.speed_gbps DESC, t.reach_meters ASC
LIMIT 20`,
values
);
// If no compatible transceivers found, suggest alternatives
let alternatives: unknown[] = [];
if (compatResult.rows.length === 0) {
// Find what form factors / speeds this switch supports based on ports_config
const portSpeeds: number[] = [];
if (sw.max_speed_gbps) portSpeeds.push(parseFloat(sw.max_speed_gbps));
// Try to find transceivers that match the requested criteria even without verified compatibility
const altConditions: string[] = [];
const altValues: unknown[] = [];
let altIdx = 1;
if (transceiver_query) {
altConditions.push(`(t.standard_name ILIKE $${altIdx} OR t.slug ILIKE $${altIdx})`);
altValues.push(`%${transceiver_query}%`);
altIdx++;
}
if (speed_gbps) {
altConditions.push(`t.speed_gbps = $${altIdx}`);
altValues.push(speed_gbps);
altIdx++;
}
if (reach) {
altConditions.push(`t.reach_label ILIKE $${altIdx}`);
altValues.push(`%${reach}%`);
altIdx++;
}
const altWhere = altConditions.length > 0 ? `WHERE ${altConditions.join(" AND ")}` : "";
const altResult = await pool.query(
`SELECT t.slug, t.standard_name, t.form_factor, t.speed, t.speed_gbps,
t.reach_label, t.fiber_type, v.name as vendor,
(SELECT MIN(po.price) FROM price_observations po
WHERE po.transceiver_id = t.id AND po.time > NOW() - INTERVAL '7 days'
) as min_price,
-- Check if this transceiver is compatible with OTHER switches from the same vendor
(SELECT COUNT(*) FROM compatibility c2
JOIN switches sw2 ON sw2.id = c2.switch_id
WHERE c2.transceiver_id = t.id
AND c2.status = 'compatible'
AND sw2.vendor_id = (SELECT vendor_id FROM switches WHERE id = '${sw.id}')
) as same_vendor_compat_count
FROM transceivers t
LEFT JOIN vendors v ON v.id = t.vendor_id
${altWhere}
ORDER BY same_vendor_compat_count DESC, t.speed_gbps DESC
LIMIT 10`,
altValues
);
alternatives = altResult.rows.map(r => ({
...r,
min_price: r.min_price ? parseFloat(r.min_price) : null,
compatibility_note: parseInt(r.same_vendor_compat_count) > 0
? `Compatible with ${r.same_vendor_compat_count} other ${sw.vendor} switches — likely compatible but NOT verified for ${sw.model}`
: "Not verified for any switches from this vendor. Test before deploying.",
}));
}
return {
content: [{
type: "text",
text: JSON.stringify({
switch: {
model: sw.model,
series: sw.series,
vendor: sw.vendor,
max_speed_gbps: sw.max_speed_gbps,
},
compatible_transceivers: compatResult.rows.map(r => ({
slug: r.slug,
standard: r.standard_name,
form_factor: r.form_factor,
speed: r.speed,
reach: r.reach_label,
fiber: r.fiber_type,
status: r.status,
firmware_min: r.firmware_min,
verified_by: r.verified_by,
method: r.verification_method,
notes: r.compat_notes,
min_price: r.min_price ? parseFloat(r.min_price) : null,
})),
count: compatResult.rows.length,
...(compatResult.rows.length === 0 && alternatives.length > 0 ? {
no_verified_match: true,
suggested_alternatives: alternatives,
suggestion_note: `No verified compatible transceivers found for ${sw.vendor} ${sw.model}. These alternatives may work based on specs and compatibility with similar ${sw.vendor} switches, but should be tested before production deployment.`,
} : {}),
}, null, 2),
}],
};
}
);
// Register tool modules
await registerPricingTools(server);
await registerCompatibilityTools(server);
await registerKnowledgeTools(server);
await registerContentTools(server);
await registerMarketTools(server);
await registerSwitchDocTools(server);
// --- Register finder.ts tools (find_flexoptix_for_switch, get_competitor_alerts) ---
for (const [toolName, toolDef] of Object.entries(finderTools)) {
const schema: Record<string, z.ZodTypeAny> = {};
for (const [propName, propDef] of Object.entries(toolDef.inputSchema.properties ?? {})) {
const p = propDef as { type: string; description?: string };
schema[propName] = p.type === "number"
? z.number().optional().describe(p.description ?? "")
: z.string().optional().describe(p.description ?? "");
}
server.tool(
toolName,
toolDef.description,
schema,
async (args) => {
const result = await handleFinderTool(toolName, args as Record<string, unknown>);
return { content: [{ type: "text" as const, text: result }] };
}
);
}
// --- Ollama LLM tools: market analysis (qwen2.5:14b) + blog generation (fo-blog-v5) ---
const OLLAMA_BASE = process.env["OLLAMA_BASE_URL"] ?? "https://ollama.fichtmueller.org";
server.tool(
"analyze_market_with_llm",
"Deep market analysis for a transceiver technology using local LLM (qwen2.5:14b). Provides expert narrative on adoption trends, pricing trajectory, competitive dynamics, and buy/wait/hold recommendation.",
{
technology: z.string().describe("Technology to analyze, e.g. '400G QSFP-DD', '800G OSFP', '100G ZR'"),
context: z.string().optional().describe("Additional context or specific questions to address"),
horizon: z.enum(["3m", "6m", "12m", "18m"]).default("12m").describe("Forecast horizon"),
},
async ({ technology, context, horizon }) => {
// Gather DB data to enrich the prompt
const [hype, prices, news] = await Promise.all([
pool.query(
`SELECT hype_phase, hype_score, ROUND(current_share*100,1) AS share_pct,
asp_current_usd, asp_decline_pct_3y, years_to_next_phase
FROM hype_cycle_analysis WHERE technology ILIKE $1
ORDER BY computed_at DESC LIMIT 1`,
[`%${technology}%`]
),
pool.query(
`SELECT v.name AS vendor, ROUND(MIN(po.price)::NUMERIC,2) AS min_price,
ROUND(MAX(po.price)::NUMERIC,2) AS max_price, po.currency
FROM price_observations po JOIN vendors v ON v.id = po.source_vendor_id
JOIN transceivers t ON t.id = po.transceiver_id
WHERE t.speed ILIKE $1 AND po.time > NOW() - INTERVAL '7 days'
GROUP BY v.name, po.currency ORDER BY min_price ASC LIMIT 10`,
[`%${technology.split("-")[0]}%`]
),
pool.query(
`SELECT title, summary, published_at FROM news_articles
WHERE content_vector @@ plainto_tsquery('english', $1)
ORDER BY published_at DESC LIMIT 5`,
[technology]
).catch(() => ({ rows: [] })),
]);
const dataContext = [
hype.rows[0] ? `Hype Cycle: phase=${hype.rows[0].hype_phase}, score=${hype.rows[0].hype_score}/100, market_share=${hype.rows[0].share_pct}%, OEM_ASP=$${hype.rows[0].asp_current_usd}, ASP_decline_3y=${hype.rows[0].asp_decline_pct_3y}%, years_to_next_phase=${hype.rows[0].years_to_next_phase}` : "",
prices.rows.length > 0 ? `Current pricing: ${prices.rows.map((r) => `${r.vendor} ${r.currency}${r.min_price}${r.max_price}`).join(", ")}` : "",
news.rows.length > 0 ? `Recent news: ${news.rows.map((r: {title:string}) => r.title).join(" | ")}` : "",
].filter(Boolean).join("\n");
const prompt = `You are a senior optical networking market analyst at a transceiver intelligence platform.
Technology: ${technology}
Forecast horizon: ${horizon}
${dataContext ? `\nLive data:\n${dataContext}` : ""}
${context ? `\nSpecific questions: ${context}` : ""}
Provide a concise expert market analysis covering:
1. Current market phase and what it means for buyers/sellers
2. Price trajectory over the next ${horizon} — will prices rise, fall, or stabilize?
3. Key demand drivers and risks
4. Competitive dynamics (OEM vs compatible vendors)
5. Buy / Wait / Hold recommendation with reasoning
Keep the analysis actionable and data-driven. Under 400 words.`;
try {
const resp = await fetch(`${OLLAMA_BASE}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: "qwen2.5:14b", prompt, stream: false }),
signal: AbortSignal.timeout(120_000),
});
if (!resp.ok) throw new Error(`Ollama HTTP ${resp.status}`);
const data = await resp.json() as { response?: string };
return { content: [{ type: "text" as const, text: data.response ?? "No response from model." }] };
} catch (err: unknown) {
return { content: [{ type: "text" as const, text: `LLM unavailable: ${(err as Error).message}. Use /api/hype-cycle/analysis for raw data.` }] };
}
}
);
server.tool(
"generate_blog_post",
"Generate a professional Flexoptix blog post using the fine-tuned fo-blog-v5 model (Ollama). Automatically enriched with live pricing, hype cycle data, and competitor analysis.",
{
topic: z.string().describe("Blog topic, e.g. '400G QSFP-DD vs 400G ZR — which for your DC?'"),
target_audience: z.enum(["network_engineer", "procurement", "executive", "general"]).default("network_engineer").describe("Target reader"),
tone: z.enum(["technical", "consultative", "educational"]).default("consultative").describe("Writing tone"),
word_count: z.number().default(600).describe("Target word count (3001000)"),
},
async ({ topic, target_audience, tone, word_count }) => {
// Gather enrichment data
const keywords = topic.match(/\b(\d+G|QSFP|SFP|OSFP|ZR|SR|LR|ER)\b/gi) ?? [];
const priceData = keywords.length > 0 ? await pool.query(
`SELECT v.name AS vendor, t.form_factor, t.speed,
ROUND(MIN(po.price)::NUMERIC,2) AS min_price, po.currency
FROM price_observations po JOIN vendors v ON v.id = po.source_vendor_id
JOIN transceivers t ON t.id = po.transceiver_id
WHERE t.speed ILIKE ANY($1) AND po.time > NOW() - INTERVAL '7 days'
GROUP BY v.name, t.form_factor, t.speed, po.currency ORDER BY min_price ASC LIMIT 8`,
[keywords.map((k: string) => `%${k}%`)]
).catch(() => ({ rows: [] })) : { rows: [] };
const enrichment = priceData.rows.length > 0
? `\nCurrent market prices (use naturally in article):\n${priceData.rows.map((r: {vendor:string;form_factor:string;speed:string;min_price:string;currency:string}) => `- ${r.form_factor} ${r.speed}: from ${r.currency}${r.min_price} at ${r.vendor}`).join("\n")}`
: "";
const systemPrompt = `You are a professional technical writer for Flexoptix, Europe's leading transceiver specialist. Write in a ${tone} tone for a ${target_audience.replace(/_/g," ")} audience. Articles should highlight Flexoptix expertise and the value of our FlexBox universal coding solution.`;
const userPrompt = `Write a ${word_count}-word blog post on: "${topic}"
${enrichment}
Include:
- Compelling introduction
- Technical explanation appropriate for audience
- Real pricing context where available
- Call-to-action mentioning Flexoptix or FlexBox
- SEO-friendly subheadings
Do not include a title (added separately). Start directly with the article body.`;
try {
const resp = await fetch(`${OLLAMA_BASE}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "fo-blog-v5",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
stream: false,
}),
signal: AbortSignal.timeout(180_000),
});
if (!resp.ok) {
// Fallback to qwen2.5:14b if fo-blog-v5 not available
const fallbackResp = await fetch(`${OLLAMA_BASE}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "qwen2.5:14b",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
stream: false,
}),
signal: AbortSignal.timeout(180_000),
});
if (!fallbackResp.ok) throw new Error(`Both fo-blog-v5 and qwen2.5:14b unavailable`);
const fallbackData = await fallbackResp.json() as { message?: { content?: string } };
return { content: [{ type: "text" as const, text: `[Generated with qwen2.5:14b — fo-blog-v5 unavailable]\n\n${fallbackData.message?.content ?? "No content"}` }] };
}
const data = await resp.json() as { message?: { content?: string } };
return { content: [{ type: "text" as const, text: data.message?.content ?? "No content generated." }] };
} catch (err: unknown) {
return { content: [{ type: "text" as const, text: `LLM unavailable: ${(err as Error).message}. Check OLLAMA_BASE_URL env var.` }] };
}
}
);
// Start server
const transport = new StdioServerTransport();
await server.connect(transport);
// Graceful shutdown
process.on("SIGINT", async () => {
await pool.end();
process.exit(0);
});
}
main().catch((err) => {
console.error("Fatal MCP server error:", err);
process.exit(1);
});