=== NEW SERVERS ADDED (7) === - servers/closebot — 119 tools, 14 modules, 4,656 lines TS (Stage 7) - servers/google-console — Google Search Console MCP (Stage 7) - servers/meta-ads — Meta/Facebook Ads MCP (Stage 8) - servers/twilio — Twilio communications MCP (Stage 8) - servers/competitor-research — Competitive intel MCP (Stage 6) - servers/n8n-apps — n8n workflow MCP apps (Stage 6) - servers/reonomy — Commercial real estate MCP (Stage 1) === FACTORY INFRASTRUCTURE ADDED === - infra/factory-tools — mcp-jest, mcp-validator, mcp-add, MCP Inspector - 60 test configs, 702 auto-generated test cases - All 30 servers score 100/100 protocol compliance - infra/command-center — Pipeline state, operator playbook, dashboard config - infra/factory-reviews — Automated eval reports === DOCS ADDED === - docs/MCP-FACTORY.md — Factory overview - docs/reports/ — 5 pipeline evaluation reports - docs/research/ — Browser MCP research === RULES ESTABLISHED === - CONTRIBUTING.md — All MCP work MUST go in this repo - README.md — Full inventory of 37 servers + infra docs - .gitignore — Updated for Python venvs TOTAL: 37 MCP servers + full factory pipeline in one repo. This is now the single source of truth for all MCP work.
433 lines
14 KiB
TypeScript
433 lines
14 KiB
TypeScript
/**
|
|
* 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<CallToolResult> => {
|
|
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<CallToolResult> => {
|
|
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<string, unknown>,
|
|
};
|
|
} 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<CallToolResult> => {
|
|
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<string, unknown>,
|
|
};
|
|
} 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<CallToolResult> => {
|
|
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<ReadResourceResult> => {
|
|
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<ReadResourceResult> => {
|
|
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;
|
|
}
|