/** * MCP Server for Competitor Research. * * Registers four tools: * - start_research: Opens the intake form UI * - analyze_competitors: Runs full analysis and returns dashboard data * - refresh_competitor: Re-researches a single competitor * - export_report: Exports the last result as markdown or JSON * * Also registers resources to serve the bundled intake-form and dashboard HTML. * * @module server */ import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { CallToolResult, ReadResourceResult, } from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import { researchCompetitor, runFullAnalysis } from "./src/research/engine.js"; import type { FocusArea, ResearchResult } from "./src/types.js"; // ─── Resolve dist directory (works from source via tsx and compiled) ──────── const DIST_DIR = import.meta.filename.endsWith(".ts") ? path.join(import.meta.dirname, "dist") : import.meta.dirname; // ─── Zod schemas for input validation ─────────────────────────────────────── const CompanyProfileSchema = z.object({ name: z.string().describe("Company name"), url: z.string().url().describe("Company website URL"), industry: z.string().describe("Industry or market category"), description: z.string().describe("Brief company description"), valueProps: z.array(z.string()).describe("Key value propositions"), }); const CompetitorEntrySchema = z.object({ name: z.string().describe("Competitor name"), url: z.string().url().describe("Competitor website URL"), notes: z.string().optional().describe("Optional notes about this competitor"), }); const FocusAreaSchema = z.enum(["pricing", "features", "content", "social", "seo", "all"]); const ResearchBriefSchema = z.object({ company: CompanyProfileSchema.describe("Your company profile"), competitors: z.array(CompetitorEntrySchema).min(1).describe("List of competitors to analyse"), focusAreas: z.array(FocusAreaSchema).min(1).describe("Aspects to focus the research on"), }); // ─── Result cache (per server factory call — effectively per session) ────── let lastResult: ResearchResult | null = null; // ─── Helpers ──────────────────────────────────────────────────────────────── /** * Format a ResearchResult as a Markdown report. */ function formatMarkdown(result: ResearchResult): string { const lines: string[] = []; const { brief, analyses, opportunities, generatedAt } = result; lines.push(`# Competitor Research Report`); lines.push(`**Company:** ${brief.company.name} `); lines.push(`**Industry:** ${brief.company.industry} `); lines.push(`**Generated:** ${new Date(generatedAt).toLocaleString()} `); lines.push(""); lines.push("## Company Overview"); lines.push(brief.company.description); lines.push(""); lines.push("**Value Propositions:**"); for (const vp of brief.company.valueProps) { lines.push(`- ${vp}`); } lines.push(""); lines.push("---"); lines.push(""); for (const analysis of analyses) { lines.push(`## ${analysis.competitor.name}`); lines.push(`**URL:** ${analysis.competitor.url} `); if (analysis.competitor.notes) { lines.push(`**Notes:** ${analysis.competitor.notes} `); } lines.push(""); if (analysis.features.length > 0) { lines.push("### Features"); for (const f of analysis.features) { lines.push(`- ${f}`); } lines.push(""); } if (analysis.pricing) { lines.push("### Pricing"); for (const tier of analysis.pricing.tiers) { lines.push(`**${tier.name}** — ${tier.price}`); for (const f of tier.features) { lines.push(` - ${f}`); } } lines.push(""); } if (analysis.socialPresence && analysis.socialPresence.length > 0) { lines.push("### Social Presence"); for (const sp of analysis.socialPresence) { lines.push(`- **${sp.platform}**${sp.followers ? ` (${sp.followers} followers)` : ""}`); } lines.push(""); } if (analysis.contentStrategy) { lines.push("### Content Strategy"); lines.push(`**Frequency:** ${analysis.contentStrategy.blogFrequency ?? "Unknown"}`); if (analysis.contentStrategy.topTopics.length > 0) { lines.push("**Top Topics:**"); for (const t of analysis.contentStrategy.topTopics) { lines.push(`- ${t}`); } } lines.push(""); } if (analysis.seoMetrics) { lines.push("### SEO Metrics"); if (analysis.seoMetrics.topKeywords.length > 0) { lines.push(`**Keywords:** ${analysis.seoMetrics.topKeywords.join(", ")}`); } lines.push(`**Est. Traffic:** ${analysis.seoMetrics.estimatedTraffic ?? "Unknown"}`); lines.push(`**Domain Authority:** ${analysis.seoMetrics.domainAuthority ?? "Unknown"}`); lines.push(""); } lines.push("### Strengths"); for (const s of analysis.strengths) { lines.push(`- ✅ ${s}`); } lines.push(""); lines.push("### Weaknesses"); for (const w of analysis.weaknesses) { lines.push(`- ⚠️ ${w}`); } lines.push(""); lines.push("---"); lines.push(""); } lines.push("## Opportunities"); for (const opp of opportunities) { lines.push(`- 🎯 ${opp}`); } lines.push(""); return lines.join("\n"); } /** * Generate a concise text summary of the research result for model consumption. */ function textSummary(result: ResearchResult): string { const { analyses, opportunities } = result; const lines: string[] = [ `Competitor Research Complete — ${analyses.length} competitor(s) analysed.`, "", ]; for (const a of analyses) { lines.push( `• ${a.competitor.name}: ${a.features.length} features found, ` + `${a.strengths.length} strengths, ${a.weaknesses.length} weaknesses` + `${a.pricing ? `, ${a.pricing.tiers.length} pricing tier(s)` : ""}`, ); } lines.push(""); lines.push(`Top opportunities: ${opportunities.slice(0, 3).join("; ")}`); return lines.join("\n"); } // ─── Server factory ───────────────────────────────────────────────────────── /** * Creates a new MCP server instance with all tools and resources registered. * * Each call returns a fresh McpServer (required for stateless HTTP transport). */ export function createServer(): McpServer { const server = new McpServer({ name: "Competitor Research MCP", version: "1.0.0", }); // ─── Resource URIs ──────────────────────────────────────────────────────── const intakeResourceUri = "ui://competitor-research/intake-form.html"; const dashboardResourceUri = "ui://competitor-research/dashboard.html"; // ─── Tool 1: start_research ─────────────────────────────────────────────── registerAppTool( server, "start_research", { title: "Start Competitor Research", description: "Opens the research intake form where you can enter your company details and competitors to analyse.", inputSchema: {}, _meta: { ui: { resourceUri: intakeResourceUri } }, }, async (): Promise => { return { content: [ { type: "text", text: "I've opened the research intake form. Please fill in your company details and competitors, then submit the form to begin analysis.", }, ], }; }, ); // ─── Tool 2: analyze_competitors ────────────────────────────────────────── registerAppTool( server, "analyze_competitors", { title: "Analyse Competitors", description: "Runs a full competitive analysis based on the research brief. Scrapes competitor websites to extract pricing, features, content strategy, social presence, and SEO metrics.", inputSchema: ResearchBriefSchema, _meta: { ui: { resourceUri: dashboardResourceUri } }, }, async (brief): Promise => { try { const result = await runFullAnalysis(brief); // Cache for export_report lastResult = result; const summary = textSummary(result); return { content: [{ type: "text", text: summary }], structuredContent: result as unknown as Record, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error("[server] analyze_competitors error:", message); return { content: [ { type: "text", text: `Analysis encountered an error: ${message}. Partial results may be available.`, }, ], isError: true, }; } }, ); // ─── Tool 3: refresh_competitor ─────────────────────────────────────────── server.registerTool( "refresh_competitor", { title: "Refresh Competitor", description: "Re-run research on a single competitor to get updated data.", inputSchema: { competitorUrl: z.string().url().describe("Competitor website URL to refresh"), focusAreas: z .array(FocusAreaSchema) .min(1) .default(["all"]) .describe("Focus areas to research"), }, annotations: { readOnlyHint: true, openWorldHint: true, }, }, async ({ competitorUrl, focusAreas }): Promise => { try { // Derive competitor name from URL let name: string; try { name = new URL(competitorUrl).hostname.replace(/^www\./, ""); } catch { name = competitorUrl; } const analysis = await researchCompetitor( { name, url: competitorUrl }, focusAreas as FocusArea[], ); return { content: [ { type: "text", text: `Refreshed analysis for ${name}: ${analysis.features.length} features, ${analysis.strengths.length} strengths, ${analysis.weaknesses.length} weaknesses.`, }, ], structuredContent: analysis as unknown as Record, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { content: [ { type: "text", text: `Refresh failed: ${message}` }, ], isError: true, }; } }, ); // ─── Tool 4: export_report ──────────────────────────────────────────────── server.registerTool( "export_report", { title: "Export Report", description: "Export the last competitor analysis as a formatted Markdown or JSON report.", inputSchema: { format: z .enum(["markdown", "json"]) .default("markdown") .describe('Report format — "markdown" or "json"'), }, }, async ({ format }): Promise => { if (!lastResult) { return { content: [ { type: "text", text: "No analysis results available yet. Run `analyze_competitors` first.", }, ], isError: true, }; } if (format === "json") { return { content: [ { type: "text", text: JSON.stringify(lastResult, null, 2), }, ], }; } return { content: [ { type: "text", text: formatMarkdown(lastResult), }, ], }; }, ); // ─── Resources: serve bundled HTML apps ─────────────────────────────────── registerAppResource( server, intakeResourceUri, intakeResourceUri, { mimeType: RESOURCE_MIME_TYPE, description: "Competitor Research Intake Form", }, async (): Promise => { const html = await fs.readFile( path.join(DIST_DIR, "app-ui", "intake-form.html"), "utf-8", ); return { contents: [ { uri: intakeResourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }, ], }; }, ); registerAppResource( server, dashboardResourceUri, dashboardResourceUri, { mimeType: RESOURCE_MIME_TYPE, description: "Competitor Research Dashboard", }, async (): Promise => { const html = await fs.readFile( path.join(DIST_DIR, "app-ui", "dashboard.html"), "utf-8", ); return { contents: [ { uri: dashboardResourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }, ], }; }, ); return server; }