import { Controller, Post, Body, Headers, HttpCode } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ProxyService } from './proxy.service'; const PROTOCOL_VERSION = '2024-11-05'; // The admin account is permanently protected — MCP tools may never touch it. const PROTECTED_ADMIN = 'admin@myblogs.com'; // ── Tool definitions ────────────────────────────────────────────────────────── // Descriptions are deliberately verbose so LLM agents have the context they // need to pick the right tool and supply correct arguments without guessing. const TOOL_DEFINITIONS = [ { name: 'manage_guest_user', description: 'Create, update, or deactivate/delete guest contributor accounts on Meridian. ' + 'Guest users can log in to the admin panel and create or edit blog posts. ' + 'The protected admin account (admin@myblogs.com) can NEVER be modified or deleted ' + 'by this tool — any attempt will be rejected. ' + 'Use action="create" to onboard a new writer, action="update" to change their details ' + 'or suspend them (is_active=false), and action="delete" to remove the account entirely.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['create', 'update', 'delete'], description: '"create" — add a new guest account. ' + '"update" — change name, password, bio, or active status. ' + '"delete" — permanently remove the account.', }, email: { type: 'string', description: 'Email address that identifies the user. Required for all actions.', }, name: { type: 'string', description: 'Full display name shown on blog posts. Required for create; optional for update.', }, password: { type: 'string', description: 'Login password (minimum 8 characters recommended). ' + 'Required for create; optional for update — omit to leave unchanged.', }, bio: { type: 'string', description: 'Short author bio (1-2 sentences) displayed on blog posts. Optional.', }, is_active: { type: 'boolean', description: 'Account status. true (default) = active and able to log in. ' + 'Set false to suspend the account without deleting it.', }, }, required: ['action', 'email'], }, }, { name: 'list_blogs', description: 'Retrieve published blog posts from Meridian with flexible filtering. ' + 'Use filter="latest" for a paginated newest-first list (supports category/tag filters). ' + 'Use filter="featured" for the top 5 most-viewed posts. ' + 'Use filter="recent" for the 6 most recently published posts. ' + 'Call list_categories and list_tags to discover valid slugs for filtering. ' + 'Results include post ID, slug, title, category, tags, excerpt, view count, and read time — ' + 'enough to answer questions like "what are the latest posts about React?" without fetching full content.', inputSchema: { type: 'object', properties: { filter: { type: 'string', enum: ['latest', 'featured', 'recent'], description: '"latest" (default) — paginated, newest first. ' + '"featured" — top 5 by all-time view count. ' + '"recent" — last 6 published.', }, category: { type: 'string', description: 'Filter by category slug (e.g. "technology", "lifestyle"). ' + 'Only applies to filter="latest". Use list_categories to see available slugs.', }, tag: { type: 'string', description: 'Filter by tag slug (e.g. "javascript", "react"). ' + 'Only applies to filter="latest". Use list_tags to see available slugs.', }, page: { type: 'integer', description: 'Page number for paginated results. Only applies to filter="latest". Default: 1.', }, limit: { type: 'integer', description: 'Posts per page. Only applies to filter="latest". Default: 10, max: 50.', }, }, }, }, { name: 'get_blog', description: 'Retrieve the complete content of a specific blog post by its URL slug. ' + 'Returns full HTML content, metadata (category, tags, author, view count, read time), ' + 'and all associated comments. ' + 'Set summarize=true to get a concise 2-3 sentence AI-generated summary of the post ' + '(requires OPENAI_API_KEY to be configured on the server). ' + 'Use this when you need to read, quote, or summarise a specific article.', inputSchema: { type: 'object', properties: { slug: { type: 'string', description: 'URL slug of the post (e.g. "understanding-javascript-closures"). ' + 'Visible in list_blogs and search_blogs results.', }, summarize: { type: 'boolean', description: 'If true, appends an AI-generated 2-3 sentence summary of the post body. ' + 'Default: false.', }, }, required: ['slug'], }, }, { name: 'search_blogs', description: 'Full-text keyword search across all published blog posts. ' + 'Searches post titles, excerpts, and full body content (case-insensitive). ' + 'Returns matching posts with title, slug, excerpt, category, and tags. ' + 'Set include_content=true to also return the full HTML body of each result — ' + 'useful when you need to quote or analyse specific passages. ' + 'This is a SQL LIKE/ILIKE search; vector/semantic search is not yet implemented. ' + 'Use specific keywords rather than natural-language questions for best results.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search keyword or phrase (e.g. "React hooks", "machine learning", "docker"). ' + 'Case-insensitive. Prefix/suffix wildcard % is supported if needed.', }, limit: { type: 'integer', description: 'Maximum number of results to return. Default: 10, max: 50.', }, include_content: { type: 'boolean', description: 'If true, includes the first 500 characters of stripped body text for each result. ' + 'Default: false.', }, }, required: ['query'], }, }, { name: 'create_blog', description: 'Publish a new blog post on Meridian. Supports rich HTML content: ' + 'headings (

,

), paragraphs (

), bold/italic, links (), ' + 'images (), ordered/unordered lists, blockquotes, and ' + 'syntax-highlighted code blocks (

...
). ' + 'Always call list_categories and list_tags first to get valid IDs. ' + 'Use a high-quality landscape image URL for featured_image (1200×630 px ideal). ' + 'Write a compelling excerpt (~160 chars) — it appears on listing cards and in SEO meta. ' + 'Set status="draft" to save privately or "published" to go live immediately.', inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Post title. Clear, descriptive, SEO-friendly (50-70 chars ideal). ' + 'The URL slug is derived automatically from this.', }, content: { type: 'string', description: 'Full post body as HTML. Structure with

/

section headings, ' + '

paragraphs,

    /
      lists,
      for pull-quotes, ' + 'and
      ...
      for code. ' + 'Aim for at least 500 words for quality content.', }, excerpt: { type: 'string', description: 'Post summary shown on listing cards and used as meta description. ' + '1-3 sentences, ~120-160 characters. Should hook the reader.', }, category_id: { type: 'integer', description: 'Numeric category ID. Use list_categories to get valid IDs.', }, tag_ids: { type: 'array', items: { type: 'integer' }, description: 'Array of numeric tag IDs. Use list_tags to get valid IDs. ' + '3-6 tags are recommended for discoverability.', }, featured_image: { type: 'string', description: 'Hero image URL or /uploads/ path. High-quality landscape images (1200×630 px) ' + 'work best. Use a relevant, royalty-free image. Unsplash URLs are accepted.', }, status: { type: 'string', enum: ['draft', 'published'], description: '"published" — immediately visible on the site. ' + '"draft" — saved privately, not visible to readers. Default: "draft".', }, author_name: { type: 'string', description: 'Display name shown as the author on the post. Defaults to "MCP Agent". ' + 'Set this to the actual author\'s name when posting on behalf of someone.', }, audio_url: { type: 'string', description: 'URL or /uploads/ path of a pre-rendered TTS mp3 for this post, if one was generated.', }, series_key: { type: 'string', description: 'Curriculum track identifier for Educational-category posts (e.g. "general-relativity"), ' + 'used to track fundamental-to-advanced progression across posts.', }, series_index: { type: 'integer', description: '0-based position of this post within its series_key track\'s topic list.', }, }, required: ['title', 'content', 'excerpt'], }, }, { name: 'update_blog', description: 'Edit an existing blog post. Only fields you explicitly provide are updated — ' + 'omitted fields retain their current values. ' + 'Fetch the current post with get_blog first to review its content before editing. ' + 'Providing tag_ids replaces the entire tag list; provide all desired tag IDs. ' + 'Changing the title regenerates the URL slug.', inputSchema: { type: 'object', properties: { id: { type: 'integer', description: 'Numeric post ID. Visible in list_blogs and get_blog results.', }, title: { type: 'string', description: 'New post title. Regenerates the URL slug.' }, content: { type: 'string', description: 'Replacement HTML body content.' }, excerpt: { type: 'string', description: 'Replacement post summary/excerpt.' }, category_id: { type: ['integer', 'null'], description: 'New category ID, or null to remove the category association.', }, tag_ids: { type: 'array', items: { type: 'integer' }, description: 'Replaces the full tag list. Provide all desired tag IDs.', }, featured_image: { type: 'string', description: 'Replacement featured image URL.' }, status: { type: 'string', enum: ['draft', 'published'], description: 'Change the publish status.', }, }, required: ['id'], }, }, { name: 'list_categories', description: 'List all blog categories with their numeric IDs, names, slugs, colors, and descriptions. ' + 'Call this before create_blog or update_blog to find valid category_id values, ' + 'or to understand the site\'s content taxonomy. ' + 'Categories represent broad topic areas (e.g. Technology, Lifestyle, Travel).', inputSchema: { type: 'object', properties: {}, }, }, { name: 'list_tags', description: 'List all available blog tags with their numeric IDs, names, and slugs. ' + 'Call this before create_blog or update_blog to find valid tag_id values. ' + 'Tags represent specific topics, technologies, or concepts within a post ' + '(e.g. javascript, docker, machine-learning). Multiple tags improve discoverability.', inputSchema: { type: 'object', properties: {}, }, }, ]; // ── Controller ──────────────────────────────────────────────────────────────── @Controller('api/mcp') export class McpController { constructor( private readonly proxy: ProxyService, private readonly jwt: JwtService, ) {} /** Short-lived JWT for internal service calls made on behalf of MCP agent. */ private agentAuth(): string { // sub=0 is required: all JwtStrategy validators read payload.sub for the user id. // Without sub, authorId arrives as undefined and blog post INSERT fails (NOT NULL). const token = this.jwt.sign( { sub: 0, id: 0, email: 'mcp-agent@meridian.internal', name: 'MCP Agent', role: 'admin' }, { expiresIn: '5m' }, ); return `Bearer ${token}`; } private rpcOk(id: any, result: any) { return { jsonrpc: '2.0', id: id ?? null, result }; } private rpcErr(id: any, code: number, message: string) { return { jsonrpc: '2.0', id: id ?? null, error: { code, message } }; } private toolResult(text: string) { return { content: [{ type: 'text', text }] }; } private toolErr(message: string) { return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true }; } @Post() @HttpCode(200) async handle( @Body() body: any, @Headers('authorization') authHeader: string, ) { const configuredKey = process.env.MCP_API_KEY; if (configuredKey) { const provided = (authHeader ?? '').replace(/^Bearer\s+/i, ''); if (provided !== configuredKey) { return this.rpcErr(body?.id ?? null, -32001, 'Unauthorized: missing or invalid MCP_API_KEY'); } } const { method, id, params } = body ?? {}; // JSON-RPC notifications have no id — acknowledge without body if (id === undefined) return {}; try { switch (method) { case 'initialize': return this.rpcOk(id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: { name: 'meridian-mcp', version: '1.0.0' }, instructions: 'Meridian blogging platform MCP server. ' + 'Use tools/list to discover available tools. ' + 'All write operations (create/update posts, manage users) require MCP_API_KEY authentication. ' + 'The admin@myblogs.com account is permanently protected.', }); case 'ping': return this.rpcOk(id, {}); case 'tools/list': return this.rpcOk(id, { tools: TOOL_DEFINITIONS }); case 'tools/call': return this.rpcOk(id, await this.dispatch(params?.name, params?.arguments ?? {})); default: return this.rpcErr(id, -32601, `Method not found: ${method}`); } } catch (e) { return this.rpcErr(id, -32603, (e as any)?.message ?? 'Internal error'); } } private async dispatch(toolName: string, args: Record) { switch (toolName) { case 'manage_guest_user': return this.manageGuestUser(args); case 'list_blogs': return this.listBlogs(args); case 'get_blog': return this.getBlog(args); case 'search_blogs': return this.searchBlogs(args); case 'create_blog': return this.createBlog(args); case 'update_blog': return this.updateBlog(args); case 'list_categories': return this.listCategories(); case 'list_tags': return this.listTags(); default: return this.toolErr(`Unknown tool "${toolName}". Call tools/list to see available tools.`); } } // ── Tool: manage_guest_user ───────────────────────────────────────────────── private async manageGuestUser(args: Record) { const { action, email, name, password, bio, is_active } = args; if (!email) return this.toolErr('"email" is required.'); if (email.toLowerCase() === PROTECTED_ADMIN.toLowerCase()) { return this.toolErr( `The admin account "${PROTECTED_ADMIN}" is permanently protected and cannot be modified or deleted by this tool.`, ); } const auth = this.agentAuth(); if (action === 'create') { if (!name || !password) return this.toolErr('"name" and "password" are required for action="create".'); try { const user = await this.proxy.forward('auth', '/users', 'POST', { email, name, password, bio: bio ?? '', role: 'guest', }, { Authorization: auth }); return this.toolResult( `Guest user created successfully.\n` + ` ID: ${user.id}\n` + ` Email: ${user.email}\n` + ` Name: ${user.name}\n` + ` Role: ${user.role}`, ); } catch (e) { return this.toolErr((e as any)?.message ?? 'Failed to create user.'); } } // update / delete — locate user by email first let users: any[]; try { users = await this.proxy.forward('auth', '/users', 'GET', null, { Authorization: auth }); } catch (e) { return this.toolErr('Could not retrieve user list: ' + ((e as any)?.message ?? '')); } const target = users.find((u: any) => u.email.toLowerCase() === email.toLowerCase()); if (!target) return this.toolErr(`No user found with email "${email}".`); if (action === 'update') { const patch: Record = {}; if (name !== undefined) patch.name = name; if (password !== undefined) patch.password = password; if (bio !== undefined) patch.bio = bio; if (is_active !== undefined) patch.isActive = is_active; if (Object.keys(patch).length === 0) return this.toolErr('No update fields provided.'); try { const u = await this.proxy.forward('auth', `/users/${target.id}`, 'PUT', patch, { Authorization: auth }); return this.toolResult( `User updated.\n ID: ${u.id} | Email: ${u.email} | Name: ${u.name} | Active: ${u.isActive}`, ); } catch (e) { return this.toolErr((e as any)?.message ?? 'Failed to update user.'); } } if (action === 'delete') { try { await this.proxy.forward('auth', `/users/${target.id}`, 'DELETE', null, { Authorization: auth }); return this.toolResult(`User deleted: ${email} (ID: ${target.id})`); } catch (e) { return this.toolErr((e as any)?.message ?? 'Failed to delete user.'); } } return this.toolErr(`Unknown action "${action}". Use "create", "update", or "delete".`); } // ── Tool: list_blogs ──────────────────────────────────────────────────────── private async listBlogs(args: Record) { const { filter = 'latest', category, tag, page = 1, limit = 10 } = args; try { if (filter === 'featured') { const posts: any[] = await this.proxy.forward('blog', '/posts/featured', 'GET'); return this.toolResult( `Top ${posts.length} featured post(s) by view count:\n\n` + posts.map((p, i) => this.formatPostSummary(i + 1, p)).join('\n\n'), ); } if (filter === 'recent') { const posts: any[] = await this.proxy.forward('blog', '/posts/recent', 'GET'); return this.toolResult( `${posts.length} most recent post(s):\n\n` + posts.map((p, i) => this.formatPostSummary(i + 1, p)).join('\n\n'), ); } // latest — paginated with optional category/tag filters const qs = new URLSearchParams({ page: String(+page), limit: String(Math.min(+limit, 50)), ...(category ? { category } : {}), ...(tag ? { tag } : {}), }); const result = await this.proxy.forward('blog', `/posts?${qs}`, 'GET'); const { posts, total, page: pg, pages } = result; return this.toolResult( `Total: ${total} post(s) | Page ${pg} of ${pages} | Showing ${posts.length}\n\n` + posts.map((p: any, i: number) => this.formatPostSummary(i + 1, p)).join('\n\n'), ); } catch (e) { return this.toolErr((e as any)?.message ?? 'Failed to fetch posts.'); } } // ── Tool: get_blog ────────────────────────────────────────────────────────── private async getBlog(args: Record) { const { slug, summarize = false } = args; if (!slug) return this.toolErr('"slug" is required.'); try { const post = await this.proxy.forward('blog', `/posts/${slug}`, 'GET'); const bodyText = this.stripHtml(post.content ?? ''); let summaryLine = ''; if (summarize && process.env.OPENAI_API_KEY) { const s = await this.aiSummarize(post.title, bodyText); if (s) summaryLine = `\n**AI Summary**: ${s}\n`; } const tags = post.tags?.map((t: any) => t.name).join(', ') || 'None'; const commentCount = Array.isArray(post.comments) ? post.comments.length : 0; return this.toolResult( `**${post.title}**\n` + `ID: ${post.id} | Slug: ${post.slug} | Status: ${post.status}\n` + `Category: ${post.category?.name ?? 'None'} | Tags: ${tags}\n` + `Author: ${post.authorName ?? 'Unknown'} | Published: ${post.createdAt?.slice(0, 10)}\n` + `Views: ${post.views ?? 0} | Read time: ${post.readTime ?? '?'} min | Comments: ${commentCount}\n` + `\n**Excerpt**: ${post.excerpt ?? '(none)'}` + summaryLine + `\n**Featured image**: ${post.featuredImage ?? '(none)'}` + `\n\n**Content** (HTML):\n${post.content ?? ''}`, ); } catch (e) { return this.toolErr((e as any)?.message ?? `Post not found: "${slug}".`); } } // ── Tool: search_blogs ────────────────────────────────────────────────────── private async searchBlogs(args: Record) { const { query, limit = 10, include_content = false } = args; if (!query) return this.toolErr('"query" is required.'); try { const qs = new URLSearchParams({ search: query, limit: String(Math.min(+limit, 50)) }); const result = await this.proxy.forward('blog', `/posts?${qs}`, 'GET'); const posts: any[] = result.posts ?? []; if (posts.length === 0) { return this.toolResult(`No published posts found matching "${query}".`); } const blocks = posts.map((p, i) => { let block = this.formatPostSummary(i + 1, p); if (include_content) { block += `\n Body snippet: ${this.stripHtml(p.content ?? '').slice(0, 500)}…`; } return block; }); return this.toolResult( `Found ${result.total} post(s) matching "${query}" (showing ${posts.length}):\n\n` + blocks.join('\n\n'), ); } catch (e) { return this.toolErr((e as any)?.message ?? 'Search failed.'); } } // ── Tool: create_blog ─────────────────────────────────────────────────────── private async createBlog(args: Record) { const { title, content, excerpt, category_id, tag_ids, featured_image, status = 'draft', author_name = 'MCP Agent', audio_url, series_key, series_index, } = args; if (!title) return this.toolErr('"title" is required.'); if (!content) return this.toolErr('"content" is required.'); if (!excerpt) return this.toolErr('"excerpt" is required.'); const auth = this.agentAuth(); try { const post = await this.proxy.forward('blog', '/posts', 'POST', { title, content, excerpt, categoryId: category_id ?? null, tagIds: tag_ids ?? [], featuredImage: featured_image ?? null, status, authorName: author_name, audioUrl: audio_url ?? null, seriesKey: series_key ?? null, seriesIndex: series_index ?? null, }, { Authorization: auth }); const tags = post.tags?.map((t: any) => t.name).join(', ') || 'None'; return this.toolResult( `Post ${status === 'published' ? 'published' : 'saved as draft'} successfully!\n` + ` ID: ${post.id}\n` + ` Title: ${post.title}\n` + ` Slug: ${post.slug}\n` + ` Status: ${post.status}\n` + ` URL path: /blog/${post.slug}\n` + ` Category: ${post.category?.name ?? 'None'}\n` + ` Tags: ${tags}\n` + ` Read time: ${post.readTime ?? '?'} min`, ); } catch (e) { return this.toolErr((e as any)?.message ?? 'Failed to create post.'); } } // ── Tool: update_blog ─────────────────────────────────────────────────────── private async updateBlog(args: Record) { const { id, title, content, excerpt, category_id, tag_ids, featured_image, status } = args; if (!id) return this.toolErr('"id" is required.'); const patch: Record = {}; if (title !== undefined) patch.title = title; if (content !== undefined) patch.content = content; if (excerpt !== undefined) patch.excerpt = excerpt; if (category_id !== undefined) patch.categoryId = category_id; if (tag_ids !== undefined) patch.tagIds = tag_ids; if (featured_image !== undefined) patch.featuredImage = featured_image; if (status !== undefined) patch.status = status; if (Object.keys(patch).length === 0) { return this.toolErr('No update fields were provided. Include at least one field to change.'); } const auth = this.agentAuth(); try { const post = await this.proxy.forward('blog', `/posts/${id}`, 'PUT', patch, { Authorization: auth }); return this.toolResult( `Post updated successfully.\n` + ` ID: ${post.id} | Slug: ${post.slug}\n` + ` Title: ${post.title}\n` + ` Status: ${post.status}\n` + ` URL: /blog/${post.slug}`, ); } catch (e) { return this.toolErr((e as any)?.message ?? `Failed to update post ID ${id}.`); } } // ── Tool: list_categories ─────────────────────────────────────────────────── private async listCategories() { try { const cats: any[] = await this.proxy.forward('blog', '/categories', 'GET'); if (!cats?.length) return this.toolResult('No categories found on this site.'); const lines = cats.map( c => ` ID: ${c.id} | Name: ${c.name} | Slug: ${c.slug} | Color: ${c.color ?? 'N/A'}\n Description: ${c.description ?? 'N/A'}`, ); return this.toolResult(`${cats.length} categories:\n\n` + lines.join('\n\n')); } catch (e) { return this.toolErr((e as any)?.message ?? 'Failed to fetch categories.'); } } // ── Tool: list_tags ───────────────────────────────────────────────────────── private async listTags() { try { const tags: any[] = await this.proxy.forward('blog', '/tags', 'GET'); if (!tags?.length) return this.toolResult('No tags found on this site.'); const lines = tags.map(t => ` ID: ${t.id} | Name: ${t.name} | Slug: ${t.slug}`); return this.toolResult(`${tags.length} tags:\n\n` + lines.join('\n')); } catch (e) { return this.toolErr((e as any)?.message ?? 'Failed to fetch tags.'); } } // ── Helpers ───────────────────────────────────────────────────────────────── private formatPostSummary(index: number, p: any): string { const tags = p.tags?.map((t: any) => t.name).join(', ') || 'None'; return ( `${index}. **${p.title}** (ID: ${p.id})\n` + ` Slug: ${p.slug}\n` + ` Category: ${p.category?.name ?? 'None'} | Tags: ${tags}\n` + ` Published: ${p.createdAt?.slice(0, 10)} | Views: ${p.views ?? 0} | Read time: ${p.readTime ?? '?'} min\n` + ` Excerpt: ${p.excerpt ?? '(none)'}` ); } private stripHtml(html: string): string { return html.replace(/<[^>]*>/g, ' ').replace(/&[a-z]+;/gi, ' ').replace(/\s+/g, ' ').trim(); } private async aiSummarize(title: string, text: string): Promise { try { const r = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, }, body: JSON.stringify({ model: 'gpt-4o-mini', messages: [ { role: 'system', content: 'Summarize the blog post in exactly 2-3 concise, informative sentences. Be specific about what the reader will learn.', }, { role: 'user', content: `Title: ${title}\n\n${text.slice(0, 3000)}` }, ], max_tokens: 120, temperature: 0.3, }), }); if (!r.ok) return ''; const data: any = await r.json(); return data.choices?.[0]?.message?.content?.trim() ?? ''; } catch { return ''; } } }