414 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* TIP MCP HTTP Server — SSE Transport
*
* Exposes all 12 TIP MCP tools over HTTP/SSE so the server can be registered
* in Claude Code's ~/.mcp.json as a remote MCP server.
*
* Endpoints:
* GET /health — Health check: { status: "ok", tools: 12 }
* GET /sse — Opens SSE stream, returns sessionId in endpoint event
* POST /message — Client-to-server messages (requires ?sessionId=...)
*
* Auth:
* All endpoints (except /health) require:
* Authorization: Bearer <MCP_SECRET>
*
* Config (env):
* MCP_HTTP_PORT — Listening port (default: 3202)
* MCP_SECRET — Bearer token for auth (required in production)
* CORS_ORIGINS — Comma-separated allowed origins (default: localhost + 127.0.0.1)
*
* ~/.mcp.json entry:
* {
* "tip": {
* "type": "sse",
* "url": "http://localhost:3202/sse",
* "headers": { "Authorization": "Bearer <MCP_SECRET>" }
* }
* }
*/
import express, { type Request, type Response, type NextFunction } from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.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 { registerSwitchDocTools } from "./tools/switch-docs.js";
import { registerMarketTools } from "./tools/market.js";
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const PORT = parseInt(process.env.MCP_HTTP_PORT ?? "3202", 10);
const MCP_SECRET = process.env.MCP_SECRET ?? "";
const CORS_ORIGINS: string[] = [
"http://localhost",
"http://127.0.0.1",
...(process.env.CORS_ORIGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean),
];
// ---------------------------------------------------------------------------
// Tool count (keep in sync with index.ts tools + tool files)
// search_transceivers, check_compatibility (index.ts) = 2
// pricing.ts: get_pricing, compare_prices, get_competitor_stock = 3
// compatibility.ts: suggest_alternatives, get_templates = 2
// knowledge.ts: search_knowledge_base, search_manuals, get_hype_cycle = 3
// content.ts: get_market_news, generate_blog_draft = 2
// switch-docs.ts: get_switch_docs, search_switches = 2
// market.ts: get_cable_recommendations, get_market_overview, get_technology_roadmap = 3
// Total = 17 registered
// ---------------------------------------------------------------------------
const TOOL_COUNT = 17;
// ---------------------------------------------------------------------------
// Build a new McpServer and register all tools (one server per SSE session)
// ---------------------------------------------------------------------------
async function createMcpServer(): Promise<McpServer> {
const server = new McpServer({
name: "tip-mcp-server",
version: "0.1.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 filter, e.g. 'Cisco', 'Juniper', 'FS.COM'"),
max_results: z.number().default(10).describe("Maximum results to return"),
},
async ({ query, form_factor, speed_gbps, reach_label, fiber_type, wdm_type, vendor, 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++;
}
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,
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,
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,
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.",
{
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 }) => {
const switchResult = await pool.query(
`SELECT s.id, s.model, s.series, 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) {
return {
content: [{
type: "text",
text: `No switch found matching "${switch_model}". Try a shorter model name or check spelling.`,
}],
};
}
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.slug, t.standard_name, t.form_factor, t.speed, t.reach_label,
t.fiber_type, c.status, c.firmware_min, c.verified_by, c.verification_method
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
);
return {
content: [{
type: "text",
text: JSON.stringify({
switch: { model: sw.model, series: sw.series, vendor: sw.vendor },
compatible_transceivers: compatResult.rows,
count: compatResult.rows.length,
}, null, 2),
}],
};
}
);
// Register remaining tools from tool modules
await registerPricingTools(server);
await registerCompatibilityTools(server);
await registerKnowledgeTools(server);
await registerContentTools(server);
await registerSwitchDocTools(server);
await registerMarketTools(server);
return server;
}
// ---------------------------------------------------------------------------
// Auth middleware
// ---------------------------------------------------------------------------
function requireAuth(req: Request, res: Response, next: NextFunction): void {
if (!MCP_SECRET) {
// No secret configured — skip auth (development mode)
next();
return;
}
const authHeader = req.headers["authorization"] ?? "";
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
if (token !== MCP_SECRET) {
res.status(401).json({ error: "Unauthorized: invalid or missing bearer token" });
return;
}
next();
}
// ---------------------------------------------------------------------------
// CORS middleware
// ---------------------------------------------------------------------------
function applyCors(req: Request, res: Response, next: NextFunction): void {
const origin = req.headers["origin"] ?? "";
const isAllowed = CORS_ORIGINS.some((allowed) =>
origin === allowed || origin.startsWith(allowed)
);
if (isAllowed) {
res.setHeader("Access-Control-Allow-Origin", origin);
}
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Allow-Credentials", "true");
if (req.method === "OPTIONS") {
res.sendStatus(204);
return;
}
next();
}
// ---------------------------------------------------------------------------
// Session registry: sessionId → SSEServerTransport
// ---------------------------------------------------------------------------
const sessions = new Map<string, SSEServerTransport>();
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
const app = express();
app.use(express.json());
app.use(applyCors);
// --- GET /health ---
app.get("/health", (_req: Request, res: Response) => {
res.json({ status: "ok", tools: TOOL_COUNT });
});
// --- GET /sse --- open SSE stream
app.get("/sse", requireAuth, async (req: Request, res: Response) => {
const transport = new SSEServerTransport("/message", res);
// Register session before starting so POST /message can find it immediately
sessions.set(transport.sessionId, transport);
transport.onclose = () => {
sessions.delete(transport.sessionId);
};
// Each SSE connection gets its own McpServer instance
const server = await createMcpServer();
await server.connect(transport);
// Propagate close event from request disconnect
req.on("close", () => {
transport.close().catch(() => {
// ignore errors on close
});
});
});
// --- POST /message --- receive client messages
app.post("/message", requireAuth, async (req: Request, res: Response) => {
const sessionId = req.query["sessionId"] as string | undefined;
if (!sessionId) {
res.status(400).json({ error: "Missing required query parameter: sessionId" });
return;
}
const transport = sessions.get(sessionId);
if (!transport) {
res.status(404).json({ error: `No active SSE session for sessionId: ${sessionId}` });
return;
}
await transport.handlePostMessage(req, res, req.body);
});
const httpServer = app.listen(PORT, () => {
console.log(`TIP MCP HTTP server listening on port ${PORT}`);
console.log(` SSE endpoint: http://localhost:${PORT}/sse`);
console.log(` Message endpoint: http://localhost:${PORT}/message`);
console.log(` Health endpoint: http://localhost:${PORT}/health`);
if (!MCP_SECRET) {
console.warn(" WARNING: MCP_SECRET is not set — auth is disabled (development mode only)");
}
});
// Graceful shutdown
process.on("SIGINT", async () => {
for (const transport of sessions.values()) {
await transport.close().catch(() => {
// ignore errors on close
});
}
sessions.clear();
await pool.end();
httpServer.close(() => {
process.exit(0);
});
});
}
main().catch((err: unknown) => {
console.error("Fatal TIP MCP HTTP server error:", err);
process.exit(1);
});