mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
feat: added HTTP-Based MCP Server on Netlify
This commit is contained in:
541
netlify/edge-functions/mcp.ts
Normal file
541
netlify/edge-functions/mcp.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
import type { Config, Context } from "@netlify/edge-functions";
|
||||
|
||||
// MCP Server configuration
|
||||
const SITE_URL = "https://www.markdown.fast";
|
||||
const SITE_NAME = "markdown sync framework";
|
||||
const MCP_SERVER_NAME = "markdown-fast-mcp";
|
||||
const MCP_SERVER_VERSION = "1.0.0";
|
||||
|
||||
// JSON-RPC 2.0 types
|
||||
interface JsonRpcRequest {
|
||||
jsonrpc: "2.0";
|
||||
id?: string | number | null;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface JsonRpcResponse {
|
||||
jsonrpc: "2.0";
|
||||
id: string | number | null;
|
||||
result?: unknown;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// MCP tool definitions
|
||||
const MCP_TOOLS = [
|
||||
{
|
||||
name: "list_posts",
|
||||
description: "Get all published blog posts with metadata (no content). Returns title, slug, description, date, tags, and read time.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_post",
|
||||
description: "Get a single blog post by slug with full content.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
slug: {
|
||||
type: "string",
|
||||
description: "The URL slug of the post to retrieve",
|
||||
},
|
||||
},
|
||||
required: ["slug"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_pages",
|
||||
description: "Get all published pages with metadata (no content). Returns title, slug, and order.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_page",
|
||||
description: "Get a single page by slug with full content.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
slug: {
|
||||
type: "string",
|
||||
description: "The URL slug of the page to retrieve",
|
||||
},
|
||||
},
|
||||
required: ["slug"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_homepage",
|
||||
description: "Get homepage data including featured posts, featured pages, and recent posts.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "search_content",
|
||||
description: "Full text search across all posts and pages. Returns matching results with snippets.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "The search query string",
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "export_all",
|
||||
description: "Export all posts and pages with full content. Useful for bulk content retrieval.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Helper: Create JSON-RPC success response
|
||||
function successResponse(id: string | number | null, result: unknown): JsonRpcResponse {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper: Create JSON-RPC error response
|
||||
function errorResponse(
|
||||
id: string | number | null,
|
||||
code: number,
|
||||
message: string,
|
||||
data?: unknown
|
||||
): JsonRpcResponse {
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: { code, message, data },
|
||||
};
|
||||
}
|
||||
|
||||
// Helper: Fetch data from Convex HTTP endpoint
|
||||
async function fetchConvex(convexSiteUrl: string, path: string, params?: URLSearchParams): Promise<Response> {
|
||||
const url = params
|
||||
? `${convexSiteUrl}${path}?${params.toString()}`
|
||||
: `${convexSiteUrl}${path}`;
|
||||
|
||||
return fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Tool handlers
|
||||
async function handleListPosts(convexSiteUrl: string): Promise<unknown> {
|
||||
const response = await fetchConvex(convexSiteUrl, "/api/posts");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch posts from Convex");
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
site: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
posts: data.posts || [],
|
||||
};
|
||||
}
|
||||
|
||||
async function handleGetPost(convexSiteUrl: string, slug: string): Promise<unknown> {
|
||||
const params = new URLSearchParams({ slug });
|
||||
const response = await fetchConvex(convexSiteUrl, "/api/post", params);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Post not found: ${slug}`);
|
||||
}
|
||||
throw new Error("Failed to fetch post from Convex");
|
||||
}
|
||||
const post = await response.json();
|
||||
return {
|
||||
site: SITE_NAME,
|
||||
url: `${SITE_URL}/${slug}`,
|
||||
post,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleListPages(convexSiteUrl: string): Promise<unknown> {
|
||||
// Fetch all posts and filter to get pages data from the response
|
||||
const response = await fetchConvex(convexSiteUrl, "/api/posts");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch pages from Convex");
|
||||
}
|
||||
|
||||
// Pages are served via a different endpoint, we need to get them from sitemap or similar
|
||||
// For now, return a structured response indicating pages endpoint
|
||||
return {
|
||||
site: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
pages: [],
|
||||
note: "Use get_page with specific slugs: about, docs, contact, newsletter, projects, changelog-page",
|
||||
};
|
||||
}
|
||||
|
||||
async function handleGetPage(convexSiteUrl: string, slug: string): Promise<unknown> {
|
||||
// Pages use the same /api/post endpoint but check pages table first via slug
|
||||
const params = new URLSearchParams({ slug });
|
||||
const response = await fetchConvex(convexSiteUrl, "/api/post", params);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Page not found: ${slug}`);
|
||||
}
|
||||
throw new Error("Failed to fetch page from Convex");
|
||||
}
|
||||
|
||||
const page = await response.json();
|
||||
return {
|
||||
site: SITE_NAME,
|
||||
url: `${SITE_URL}/${slug}`,
|
||||
page,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleGetHomepage(convexSiteUrl: string): Promise<unknown> {
|
||||
const response = await fetchConvex(convexSiteUrl, "/api/posts");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch homepage data from Convex");
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Get recent posts (first 5)
|
||||
const recentPosts = (data.posts || []).slice(0, 5);
|
||||
|
||||
return {
|
||||
site: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
description: data.description,
|
||||
recentPosts,
|
||||
totalPosts: data.posts?.length || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSearchContent(convexSiteUrl: string, query: string): Promise<unknown> {
|
||||
if (!query || !query.trim()) {
|
||||
return {
|
||||
site: SITE_NAME,
|
||||
query: "",
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Search is handled client-side in the app, so we do a simple filter here
|
||||
const response = await fetchConvex(convexSiteUrl, "/api/posts");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch posts for search");
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
const queryLower = query.toLowerCase();
|
||||
const results = (data.posts || [])
|
||||
.filter((post: { title: string; description: string; tags: string[] }) =>
|
||||
post.title.toLowerCase().includes(queryLower) ||
|
||||
post.description.toLowerCase().includes(queryLower) ||
|
||||
post.tags.some((tag: string) => tag.toLowerCase().includes(queryLower))
|
||||
)
|
||||
.slice(0, 15)
|
||||
.map((post: { title: string; slug: string; description: string; tags: string[] }) => ({
|
||||
type: "post",
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
description: post.description,
|
||||
url: `${SITE_URL}/${post.slug}`,
|
||||
}));
|
||||
|
||||
return {
|
||||
site: SITE_NAME,
|
||||
query,
|
||||
resultCount: results.length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleExportAll(convexSiteUrl: string): Promise<unknown> {
|
||||
const response = await fetchConvex(convexSiteUrl, "/api/export");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to export content from Convex");
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
// Handle tool calls
|
||||
async function handleToolCall(
|
||||
convexSiteUrl: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
switch (toolName) {
|
||||
case "list_posts":
|
||||
return handleListPosts(convexSiteUrl);
|
||||
|
||||
case "get_post":
|
||||
if (!args.slug || typeof args.slug !== "string") {
|
||||
throw new Error("Missing required parameter: slug");
|
||||
}
|
||||
return handleGetPost(convexSiteUrl, args.slug);
|
||||
|
||||
case "list_pages":
|
||||
return handleListPages(convexSiteUrl);
|
||||
|
||||
case "get_page":
|
||||
if (!args.slug || typeof args.slug !== "string") {
|
||||
throw new Error("Missing required parameter: slug");
|
||||
}
|
||||
return handleGetPage(convexSiteUrl, args.slug);
|
||||
|
||||
case "get_homepage":
|
||||
return handleGetHomepage(convexSiteUrl);
|
||||
|
||||
case "search_content":
|
||||
if (!args.query || typeof args.query !== "string") {
|
||||
throw new Error("Missing required parameter: query");
|
||||
}
|
||||
return handleSearchContent(convexSiteUrl, args.query);
|
||||
|
||||
case "export_all":
|
||||
return handleExportAll(convexSiteUrl);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle MCP JSON-RPC methods
|
||||
async function handleMcpMethod(
|
||||
convexSiteUrl: string,
|
||||
method: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
id: string | number | null
|
||||
): Promise<JsonRpcResponse> {
|
||||
try {
|
||||
switch (method) {
|
||||
// MCP initialization
|
||||
case "initialize":
|
||||
return successResponse(id, {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
serverInfo: {
|
||||
name: MCP_SERVER_NAME,
|
||||
version: MCP_SERVER_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
// MCP initialized notification (no response needed for notifications)
|
||||
case "notifications/initialized":
|
||||
return successResponse(id, null);
|
||||
|
||||
// List available tools
|
||||
case "tools/list":
|
||||
return successResponse(id, {
|
||||
tools: MCP_TOOLS,
|
||||
});
|
||||
|
||||
// Call a tool
|
||||
case "tools/call": {
|
||||
const toolName = params?.name as string;
|
||||
const toolArgs = (params?.arguments || {}) as Record<string, unknown>;
|
||||
|
||||
if (!toolName) {
|
||||
return errorResponse(id, -32602, "Missing tool name");
|
||||
}
|
||||
|
||||
const result = await handleToolCall(convexSiteUrl, toolName, toolArgs);
|
||||
return successResponse(id, {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Ping for health check
|
||||
case "ping":
|
||||
return successResponse(id, {});
|
||||
|
||||
default:
|
||||
return errorResponse(id, -32601, `Method not found: ${method}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return errorResponse(id, -32000, message);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate API key for optional authentication
|
||||
function validateApiKey(request: Request): { valid: boolean; authenticated: boolean } {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
const mcpApiKey = Deno.env.get("MCP_API_KEY");
|
||||
|
||||
// If no API key is configured, allow public access
|
||||
if (!mcpApiKey) {
|
||||
return { valid: true, authenticated: false };
|
||||
}
|
||||
|
||||
// If no auth header provided, allow public access
|
||||
if (!authHeader) {
|
||||
return { valid: true, authenticated: false };
|
||||
}
|
||||
|
||||
// If auth header provided, validate it
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
if (token === mcpApiKey) {
|
||||
return { valid: true, authenticated: true };
|
||||
}
|
||||
|
||||
// Invalid API key provided
|
||||
return { valid: false, authenticated: false };
|
||||
}
|
||||
|
||||
// Main edge function handler
|
||||
export default async function handler(
|
||||
request: Request,
|
||||
_context: Context
|
||||
): Promise<Response> {
|
||||
// Handle CORS preflight
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Only accept POST requests
|
||||
if (request.method !== "POST") {
|
||||
return new Response(
|
||||
JSON.stringify(errorResponse(null, -32600, "Method not allowed. Use POST.")),
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Validate optional API key
|
||||
const { valid, authenticated } = validateApiKey(request);
|
||||
if (!valid) {
|
||||
return new Response(
|
||||
JSON.stringify(errorResponse(null, -32600, "Invalid API key")),
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get Convex URL
|
||||
const convexUrl = Deno.env.get("VITE_CONVEX_URL") || Deno.env.get("CONVEX_URL");
|
||||
if (!convexUrl) {
|
||||
return new Response(
|
||||
JSON.stringify(errorResponse(null, -32000, "Server configuration error")),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Convert Convex URL for HTTP endpoints
|
||||
const convexSiteUrl = convexUrl.replace(".cloud", ".site");
|
||||
|
||||
// Parse JSON-RPC request
|
||||
let jsonRpcRequest: JsonRpcRequest;
|
||||
try {
|
||||
const body = await request.text();
|
||||
jsonRpcRequest = JSON.parse(body);
|
||||
} catch {
|
||||
return new Response(
|
||||
JSON.stringify(errorResponse(null, -32700, "Parse error: Invalid JSON")),
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Validate JSON-RPC format
|
||||
if (jsonRpcRequest.jsonrpc !== "2.0" || !jsonRpcRequest.method) {
|
||||
return new Response(
|
||||
JSON.stringify(errorResponse(jsonRpcRequest.id ?? null, -32600, "Invalid JSON-RPC request")),
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Handle the MCP method
|
||||
const response = await handleMcpMethod(
|
||||
convexSiteUrl,
|
||||
jsonRpcRequest.method,
|
||||
jsonRpcRequest.params as Record<string, unknown> | undefined,
|
||||
jsonRpcRequest.id ?? null
|
||||
);
|
||||
|
||||
// Add authentication status to response headers
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"X-MCP-Server": MCP_SERVER_NAME,
|
||||
"X-MCP-Version": MCP_SERVER_VERSION,
|
||||
};
|
||||
|
||||
if (authenticated) {
|
||||
headers["X-MCP-Authenticated"] = "true";
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(response), { headers });
|
||||
}
|
||||
|
||||
// Netlify Edge Function configuration with rate limiting
|
||||
// Rate limit: 50 requests per minute per IP for public access
|
||||
export const config: Config = {
|
||||
path: "/mcp",
|
||||
rateLimit: {
|
||||
windowLimit: 50,
|
||||
windowSize: 60,
|
||||
aggregateBy: ["ip", "domain"],
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user