Jake Shore f3c4cd817b Add all MCP servers + factory infra to MCPEngine — 2026-02-06
=== 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.
2026-02-06 06:32:29 -05:00

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;
}