import { IncomingMessage, ServerResponse } from 'http'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; interface ProjectAccessPolicy { /** Account-wide grants cover existing and future projects. */ all: boolean; /** Explicit allow-list when all is false. */ projectIds: string[]; } interface McpConfig { apiBaseUrl: string; apiKey: string; /** Internal browser JWT used by the in-process OAuth transport only. */ accessToken?: string; defaultProjectId?: string; /** OAuth-only project boundary. Undefined keeps API-key callers unchanged. */ projectAccess?: ProjectAccessPolicy; /** Persist access to a project created through a restricted OAuth grant. */ onProjectCreated?: (projectId: string) => Promise; /** * Restrict the surface to these tool names. * * Set when the caller authenticated with an OAuth token whose scope covers * only part of the surface. Tools outside the list are never registered, so * they do not appear in `tools/list` at all — an agent holding `mcp:read` * sees a read-only server rather than a full one that errors on use, which * is both honest and stops it planning work it cannot perform. * * Undefined means the whole surface, which is what an API key gets. */ allowedTools?: string[]; } /** * One MCP request over streamable HTTP, independent of who is listening. * * Deliberately its own module with no side effects on import. `http.ts` is an * executable entrypoint — it calls `main()` at module scope — so anything that * imports the handler from there would boot a second listener as a side * effect. The API mounts this handler inside the existing NestJS process * (`apps/api/src/mcp/mcp.middleware.ts`), which is what makes * `POST api.motiblog.ai/mcp` a real endpoint rather than a second deployment. */ declare function extractKey(req: IncomingMessage): string | null; declare function reject(res: ServerResponse, status: number, message: string): void; interface McpRequestOptions { apiBaseUrl: string; defaultProjectId?: string; /** * Credential to use for the tool layer's calls, when the caller presented * something other than a project API key (an OAuth bearer token, say, which * the host has already resolved to a project). */ apiKeyOverride?: string; /** * Short-lived user JWT for account-scoped OAuth loopback calls. The public * OAuth bearer is never forwarded to the REST API. */ accessTokenOverride?: string; /** OAuth-only hard boundary for project list and project-specific routes. */ projectAccess?: ProjectAccessPolicy; /** Persist access after this OAuth grant creates a project. */ onProjectCreated?: (projectId: string) => Promise; /** Restrict the surface — see McpConfig.allowedTools. */ allowedTools?: string[]; } /** * Path-agnostic on purpose: the caller owns the mount point. The standalone * server checks for `/mcp` itself; when Express mounts this at `/mcp` the * prefix is already stripped by the time we are called, so a path check here * would reject every request. * * `parsedBody` matters when a host framework has already consumed the request * stream. NestJS installs body-parser during `NestFactory.create`, so by the * time our middleware runs the stream is drained and `req.body` holds the * JSON. Passing it through is what stops the transport waiting forever on a * stream that will never emit another byte. */ declare function handleMcpRequest(req: IncomingMessage, res: ServerResponse, opts: McpRequestOptions, parsedBody?: unknown): Promise; /** * Minimal shapes of the API resources this server surfaces to agents. * Mirrors apps/api/prisma/schema.prisma — only fields useful in tool output * are declared; unknown extra fields pass through untouched. */ type ArticleStatus = 'DRAFT' | 'GENERATING' | 'REVIEW' | 'APPROVED' | 'PUBLISHING' | 'PUBLISHED' | 'FAILED' | 'SKIPPED'; interface Project { id: string; name: string; url: string; status: string; autoPublish: boolean; requireApproval: boolean; language: string; aiModel?: string; factCheckStrict?: boolean; selfHostedBlog?: boolean; articlesUsed?: number; articlesLimit?: number; bannerStyle?: string; bannerEnabled?: boolean; internalLinks?: number; blogIndexUrl?: string | null; billingStatus?: string; businessProfile?: unknown; manualPositioning?: string | null; productTruth?: unknown; categories?: string[]; backlinkExchangeEnabled?: boolean; backlinkMinDR?: number; backlinkDR?: number | null; } interface CreateProjectInput { url: string; name?: string; language?: string; blogIndexUrl?: string; manualPositioning?: string; requireApproval?: boolean; bannerStyle?: string; bannerEnabled?: boolean; internalLinks?: number; } interface ContentPlan { id: string; projectId: string; title: string; summary?: string | null; contentType?: string | null; targetKeyword?: string | null; status: 'DRAFT' | 'APPROVED' | 'IN_PROGRESS' | 'COMPLETED'; scheduledDate?: string | null; funnelStage?: string | null; } interface ArticleSummary { id: string; title: string; slug: string; status: ArticleStatus; wordCount?: number; metaDescription?: string | null; publishedAt?: string | null; scheduledFor?: string | null; topics?: string[]; seoScore?: number | null; } interface FactCheckReportItem { claim?: string; verdict?: string; source?: string; correction?: string; [key: string]: unknown; } interface Article extends ArticleSummary { content?: string; generationPhase?: string | null; factCheckReport?: { items?: FactCheckReportItem[]; verified?: number; unverified?: number; } | null; topicGate?: Record | null; publishedUrl?: string | null; bannerUrl?: string | null; updatedAt?: string; createdAt?: string; [key: string]: unknown; } interface PipelineLog { id: string; phase: string; status: string; message?: string | null; tokensIn?: number | null; tokensOut?: number | null; costUsd?: number | null; createdAt?: string; [key: string]: unknown; } interface Integration { id: string; type: string; name: string; enabled: boolean; lastUsedAt?: string | null; config?: Record; } interface PublishLog { id: string; articleId: string; integrationId: string; status: 'SUCCESS' | 'FAILED'; publishedUrl?: string | null; externalId?: string | null; error?: string | null; createdAt?: string; [key: string]: unknown; } interface Keyword { id: string; keyword: string; searchVolume?: number | null; difficulty?: number | null; intent?: string | null; status?: string | null; [key: string]: unknown; } interface ProprietaryFact { id: string; fact: string; category?: string[]; active?: boolean; [key: string]: unknown; } interface RequestOptions { query?: Record; body?: unknown; form?: FormData; } declare class MotiblogApiClient { private readonly apiBaseUrl; private readonly apiKey; private readonly fetchImpl; private readonly accessToken?; private readonly projectAccess?; private readonly onProjectCreated?; constructor(apiBaseUrl: string, apiKey: string, fetchImpl?: typeof fetch, accessToken?: string | undefined, projectAccess?: ProjectAccessPolicy | undefined, onProjectCreated?: ((projectId: string) => Promise) | undefined); private canAccessProject; private assertProjectPath; request(method: string, path: string, opts?: RequestOptions): Promise; private requestOnce; listProjects(): Promise; createProject(input: CreateProjectInput): Promise; getProject(projectId: string): Promise; updateProject(projectId: string, patch: Record): Promise; pipelineStart(projectId: string): Promise<{ message: string; }>; pipelineStatus(projectId: string): Promise>; createContentPlanFromGap(projectId: string, topic: string): Promise; listContentPlans(projectId: string): Promise; approveContentPlan(projectId: string, planId: string): Promise; regenerateContentPlan(projectId: string, planId: string): Promise; rescheduleContentPlan(projectId: string, planId: string, scheduledDate: string): Promise; createContentPlan(projectId: string, input: Record): Promise; updateContentPlan(projectId: string, planId: string, patch: Record): Promise; bulkRescheduleContentPlans(projectId: string, entries: Array<{ planId: string; scheduledDate: string; }>): Promise; deleteContentPlan(projectId: string, planId: string): Promise; restoreContentPlan(projectId: string, planId: string): Promise; listDeletedContentPlans(projectId: string): Promise; generateArticleFromPlan(projectId: string, planId: string): Promise
; getCalendar(projectId: string, start: string, end: string): Promise; listArticles(projectId: string, status?: string): Promise; getArticle(projectId: string, articleId: string): Promise
; updateArticle(projectId: string, articleId: string, patch: { title?: string; content?: string; metaDescription?: string; status?: string; }): Promise
; scheduleArticle(projectId: string, articleId: string, scheduledFor: string | null): Promise
; approveAndPublish(projectId: string, articleId: string): Promise
; regenerateArticle(projectId: string, articleId: string): Promise
; regenerateChapter(projectId: string, articleId: string, chapterIndex: number): Promise
; getPipelineLogs(projectId: string, articleId: string): Promise; getArticleFeedbackStats(articleId: string): Promise; listRefreshSuggestions(): Promise; dismissRefreshSuggestion(projectId: string, articleId: string): Promise; generateProgrammaticArticles(projectId: string, dto: { titleTemplate: string; rows: Array>; keywordColumn?: string; scheduleFrom?: string; }): Promise<{ created: number; articleIds: string[]; }>; generateImage(projectId: string, prompt: string, additionalInstructions?: string): Promise<{ url: string; }>; rewriteText(text: string, action: string): Promise<{ text: string; }>; generateArticleBanner(projectId: string, articleId: string): Promise<{ bannerUrl: string; }>; deleteArticleBanner(projectId: string, articleId: string): Promise<{ success: true; }>; getBannerPreview(projectId: string): Promise>; suggestBannerPrompt(projectId: string, stylePreset?: string): Promise<{ prompt: string; }>; generateBannerPreview(projectId: string, dto: { samples?: string[]; query?: string; stylePreset?: string; }): Promise>; private uploadRemoteImage; uploadArticleBannerFromUrl(projectId: string, articleId: string, sourceUrl: string): Promise<{ bannerUrl: string; }>; uploadImageFromUrl(projectId: string, sourceUrl: string): Promise<{ url: string; }>; listIntegrations(projectId: string): Promise; createIntegration(projectId: string, dto: { type: string; name: string; config: Record; enabled?: boolean; }): Promise; getIntegration(projectId: string, integrationId: string): Promise; updateIntegration(projectId: string, integrationId: string, dto: { type?: string; name?: string; config?: Record; enabled?: boolean; }): Promise; deleteIntegration(projectId: string, integrationId: string): Promise; testIntegration(projectId: string, integrationId: string, mode?: string): Promise; publishToIntegration(projectId: string, articleId: string, integrationId: string): Promise; retryPublish(projectId: string, articleId: string, integrationId: string): Promise; markIntegrationSynced(projectId: string, articleId: string, integrationId: string, synced?: boolean): Promise; listPublishLogs(projectId: string, articleId?: string, limit?: number): Promise; addKeyword(projectId: string, dto: { keyword: string; searchVolume?: number; difficulty?: number; intent?: string; }): Promise; listKeywords(projectId: string): Promise; updateKeyword(projectId: string, keywordId: string, status: string): Promise; deleteKeyword(projectId: string, keywordId: string): Promise; listCompetitors(projectId: string): Promise; addCompetitor(projectId: string, dto: { domain: string; url?: string; }): Promise; deleteCompetitor(projectId: string, competitorId: string): Promise; getBrandVoice(projectId: string): Promise; refreshBrandVoice(projectId: string): Promise; previewBrandVoice(projectId: string, targetKeyword?: string): Promise; listAuthors(projectId: string): Promise; getAuthor(projectId: string, authorId: string): Promise; createAuthor(projectId: string, dto: Record): Promise; updateAuthor(projectId: string, authorId: string, dto: Record): Promise; deleteAuthor(projectId: string, authorId: string): Promise; getContentIntelligenceConfig(projectId: string): Promise; updateContentIntelligenceConfig(projectId: string, sourceType: string, url: string): Promise; detectContentLinks(projectId: string): Promise; listContentPages(projectId: string, search?: string): Promise; deleteContentPage(projectId: string, pageId: string): Promise; getContentIntelligenceStats(projectId: string): Promise; startCrawl(projectId: string, full?: boolean): Promise; getCrawlStatus(projectId: string): Promise; listCrawledPages(projectId: string, page?: number, limit?: number): Promise; getCrawledPage(projectId: string, pageId: string): Promise; getSiteAnalysis(projectId: string): Promise; retryPipeline(projectId: string, runId: string): Promise; listPillarClusters(projectId: string): Promise; getSearchConsoleStatus(projectId: string): Promise; getSearchConsoleConnectUrl(projectId: string): Promise<{ authUrl: string; }>; listSearchConsoleProperties(projectId: string): Promise; selectSearchConsoleProperty(projectId: string, siteUrl: string): Promise; disconnectSearchConsole(projectId: string): Promise; getSearchConsoleDateRange(projectId: string): Promise; getSearchConsolePerformance(projectId: string, query: Record): Promise; getSearchConsoleQueries(projectId: string, query: Record): Promise; getSearchConsolePages(projectId: string, query: Record): Promise; getSearchConsoleInsights(projectId: string, type?: string, status?: string): Promise; dismissSearchConsoleInsight(projectId: string, insightId: string): Promise; getArticlePositionHistory(projectId: string, articleId: string, days?: number): Promise; getSearchConsoleActionQueue(projectId: string): Promise; syncSearchConsole(projectId: string): Promise; getAccountEntitlement(): Promise>; getAccountProfile(): Promise>; updateAccountProfile(dto: { name?: string; avatarUrl?: string; }): Promise>; listNotifications(limit?: number, unreadOnly?: boolean): Promise; getUnreadNotificationCount(): Promise<{ count: number; }>; markNotificationRead(notificationId: string): Promise; markAllNotificationsRead(): Promise; getProductFact(projectId: string, factId: string): Promise; deleteProductFact(projectId: string, factId: string): Promise; listProductFacts(projectId: string, activeOnly?: boolean): Promise; createProductFact(projectId: string, dto: { fact: string; category?: string[]; }): Promise; updateProductFact(projectId: string, factId: string, dto: { fact?: string; category?: string[]; active?: boolean; }): Promise; listAgentNotes(projectId: string): Promise; getAgentNote(projectId: string, key: string): Promise; saveAgentNote(projectId: string, key: string, dto: { title: string; body: string; kind?: string; pinned?: boolean; }): Promise; deleteAgentNote(projectId: string, key: string): Promise; searchAgentNotes(projectId: string, dto: { query: string; limit?: number; }): Promise; } /** * Tool surface for the MotiBlog MCP server. * * Design (docs/product/agent-first-direction.md): coordination verbs over the * content lifecycle — the agent steers (topics → review → approve → publish / * export) but never bypasses governance: publication always flows through the * API's typed approval gate with provenance. * * Every tool description is self-contained so a fresh agent session can pick * the right tool without extra docs. */ interface ToolContext { client: MotiblogApiClient; defaultProjectId?: string; } interface ToolResult { text: string; isError?: boolean; } interface ToolDefinition { name: string; description: string; inputSchema: z.ZodRawShape; /** * Required, not optional — a tool without these is a tool a client cannot * present or guard. `title` is what a tool picker shows a human; * `readOnlyHint` decides whether a client runs it without confirming; and * `destructiveHint` decides whether it warns first. Anthropic's connector * directory rejects submissions missing them (see * docs/product/mcp-distribution.md), but the reason to have them is that * `approve_publication` writes to a customer's live domain and a client * needs to know that before it fires. * * destructiveHint means: deletes data, overwrites something the caller may * have edited, or reaches the public internet in a way that is awkward to * take back. */ annotations: { title: string; readOnlyHint: boolean; destructiveHint: boolean; idempotentHint?: boolean; openWorldHint?: boolean; }; handler: (args: Record, ctx: ToolContext) => Promise; } declare const toolDefinitions: ToolDefinition[]; /** * Build the MotiBlog MCP server with the full tool surface. * * Shared by both entrypoints: * - src/index.ts (stdio, local agents) * - src/http.ts (streamable HTTP, remote agents) * * The config is injected rather than loaded once so HTTP mode can * authenticate each request with its own caller's API key. */ declare function buildServer(config: McpConfig): McpServer; export { type McpConfig, type McpRequestOptions, MotiblogApiClient, buildServer, extractKey, handleMcpRequest, reject, toolDefinitions };