Files
hatchet/frontend/docs/pages/api/mcp.ts
2026-02-21 08:49:28 -08:00

654 lines
18 KiB
TypeScript

/**
* MCP (Model Context Protocol) server for Hatchet documentation.
*
* Implements the Streamable HTTP transport (stateless mode) so that
* AI editors like Cursor, Claude Code, and Claude Desktop can query
* Hatchet docs as MCP resources.
*
* Endpoint: POST /api/mcp (JSON-RPC 2.0)
* GET /api/mcp (returns server metadata)
*/
import type { NextApiRequest, NextApiResponse } from "next";
import fs from "node:fs";
import path from "node:path";
import { PostHog } from "posthog-node";
// ---------------------------------------------------------------------------
// 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 };
}
interface McpResource {
uri: string;
name: string;
description: string;
mimeType: string;
}
interface DocEntry {
uri: string;
name: string;
description: string;
filePath: string;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PROTOCOL_VERSION = "2024-11-05";
const SERVER_NAME = "hatchet-docs";
const SERVER_VERSION = "1.0.0";
const LLMS_DIR = path.join(process.cwd(), "public", "llms");
const LLMS_TXT_PATH = path.join(process.cwd(), "public", "llms.txt");
const SEARCH_INDEX_PATH = path.join(
process.cwd(),
"public",
"llms-search-index.json",
);
// ---------------------------------------------------------------------------
// PostHog server-side analytics
// ---------------------------------------------------------------------------
let posthogClient: PostHog | null = null;
function getPostHog(): PostHog | null {
if (posthogClient) return posthogClient;
const key = process.env.NEXT_PUBLIC_POSTHOG_KEY;
if (!key) return null;
posthogClient = new PostHog(key, {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com",
flushAt: 10,
flushInterval: 5000,
});
return posthogClient;
}
function trackMcpEvent(
req: NextApiRequest,
method: string,
properties?: Record<string, unknown>,
): void {
const ph = getPostHog();
if (!ph) return;
// Use a session ID from the MCP client if available, otherwise anonymous
const sessionId = (req.headers["mcp-session-id"] as string) || "anonymous";
ph.capture({
distinctId: `mcp:${sessionId}`,
event: "mcp_request",
properties: {
method,
user_agent: req.headers["user-agent"] || "",
...properties,
},
});
}
// ---------------------------------------------------------------------------
// MiniSearch index (pre-generated by scripts/generate-llms.ts)
// ---------------------------------------------------------------------------
import MiniSearch from "minisearch";
import {
MINISEARCH_OPTIONS,
SEARCH_OPTIONS,
rerankResults,
expandSynonyms,
} from "@/lib/search-config";
interface SearchDoc {
id: string;
title: string;
content: string;
}
let cachedMiniSearch: MiniSearch<SearchDoc> | null = null;
function getSearchIndex(): MiniSearch<SearchDoc> | null {
if (cachedMiniSearch) return cachedMiniSearch;
try {
const raw = fs.readFileSync(SEARCH_INDEX_PATH, "utf-8");
cachedMiniSearch = MiniSearch.loadJSON<SearchDoc>(raw, MINISEARCH_OPTIONS);
return cachedMiniSearch;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Build the resource catalogue from public/llms/
// ---------------------------------------------------------------------------
let cachedDocs: DocEntry[] | null = null;
function collectDocs(): DocEntry[] {
if (cachedDocs) return cachedDocs;
const entries: DocEntry[] = [];
// Parse llms.txt to get titles and URLs
const titleMap = new Map<string, string>();
if (fs.existsSync(LLMS_TXT_PATH)) {
const llmsTxt = fs.readFileSync(LLMS_TXT_PATH, "utf-8");
const linkPattern =
/- \[([^\]]+)\]\(https:\/\/docs\.hatchet\.run\/([^)]+)\)/g;
let m: RegExpExecArray | null;
while ((m = linkPattern.exec(llmsTxt)) !== null) {
titleMap.set(m[2], m[1]);
}
}
function walk(dir: string, prefix: string): void {
if (!fs.existsSync(dir)) return;
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
if (item.isDirectory()) {
walk(
path.join(dir, item.name),
prefix ? `${prefix}/${item.name}` : item.name,
);
} else if (item.name.endsWith(".md")) {
const slug = item.name.replace(/\.md$/, "");
const docPath = prefix ? `${prefix}/${slug}` : slug;
// Skip duplicates (e.g. home.md vs home/index.md)
if (
slug === "index" &&
entries.some((e) => e.uri === `hatchet://docs/${prefix}`)
) {
continue;
}
const lookupKey = slug === "index" ? `${prefix}/index` : docPath;
const title = titleMap.get(lookupKey) || titleMap.get(docPath) || slug;
const uri = `hatchet://docs/${docPath}`;
if (entries.some((e) => e.uri === uri)) continue;
entries.push({
uri,
name: title,
description: `Hatchet documentation: ${title}`,
filePath: path.join(dir, item.name),
});
}
}
}
walk(LLMS_DIR, "");
cachedDocs = entries;
return entries;
}
// ---------------------------------------------------------------------------
// MCP method handlers
// ---------------------------------------------------------------------------
function handleInitialize(id: string | number | null): JsonRpcResponse {
return {
jsonrpc: "2.0",
id,
result: {
protocolVersion: PROTOCOL_VERSION,
capabilities: {
resources: { listChanged: false },
tools: {},
},
serverInfo: {
name: SERVER_NAME,
version: SERVER_VERSION,
},
},
};
}
function handleResourcesList(id: string | number | null): JsonRpcResponse {
const docs = collectDocs();
const resources: McpResource[] = docs.map((d) => ({
uri: d.uri,
name: d.name,
description: d.description,
mimeType: "text/markdown",
}));
return {
jsonrpc: "2.0",
id,
result: { resources },
};
}
function handleResourcesRead(
id: string | number | null,
params: Record<string, unknown>,
): JsonRpcResponse {
const uri = params.uri as string | undefined;
if (!uri) {
return {
jsonrpc: "2.0",
id,
error: { code: -32602, message: "Missing required parameter: uri" },
};
}
const docs = collectDocs();
const doc = docs.find((d) => d.uri === uri);
if (!doc) {
return {
jsonrpc: "2.0",
id,
error: { code: -32602, message: `Resource not found: ${uri}` },
};
}
let content = "";
try {
content = fs.readFileSync(doc.filePath, "utf-8");
} catch {
return {
jsonrpc: "2.0",
id,
error: { code: -32603, message: `Failed to read resource: ${uri}` },
};
}
return {
jsonrpc: "2.0",
id,
result: {
contents: [
{
uri: doc.uri,
mimeType: "text/markdown",
text: content,
},
],
},
};
}
function handleToolsList(id: string | number | null): JsonRpcResponse {
return {
jsonrpc: "2.0",
id,
result: {
tools: [
{
name: "search_docs",
description:
"Search Hatchet documentation by keyword. Returns matching page titles and URIs.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description:
"Search query (keywords to match against page titles and content)",
},
max_results: {
type: "number",
description:
"Maximum number of results to return (default: 10)",
},
},
required: ["query"],
},
},
],
},
};
}
function handleToolsCall(
id: string | number | null,
params: Record<string, unknown>,
): JsonRpcResponse {
const toolName = params.name as string | undefined;
const args = (params.arguments || {}) as Record<string, unknown>;
if (toolName === "search_docs") {
return handleSearchDocs(id, args);
}
if (toolName === "get_full_docs") {
return handleGetFullDocs(id);
}
return {
jsonrpc: "2.0",
id,
error: { code: -32602, message: `Unknown tool: ${toolName}` },
};
}
function handleSearchDocs(
id: string | number | null,
args: Record<string, unknown>,
): JsonRpcResponse {
const query = ((args.query as string) || "").trim();
const maxResults = (args.max_results as number) || 10;
if (!query) {
return {
jsonrpc: "2.0",
id,
error: { code: -32602, message: "Missing required argument: query" },
};
}
const miniSearch = getSearchIndex();
const docCatalogue = collectDocs();
// Build a lookup from URI to DocEntry for snippet extraction
const docByUri = new Map<string, DocEntry>();
for (const d of docCatalogue) {
docByUri.set(d.uri, d);
}
let results: Array<{ uri: string; title: string; score: number }> = [];
if (miniSearch) {
// Expand synonyms so alternate phrasings (e.g. "delay" → "schedule sleep")
// still surface the right pages, then rerank against the original query.
const expanded = expandSynonyms(query);
const searchResults = miniSearch.search(expanded, SEARCH_OPTIONS);
const reranked = rerankResults(searchResults, query);
const seenPages = new Set<string>();
const deduped: typeof reranked = [];
for (const r of reranked) {
const pageUri = r.id.replace(/#.*$/, "");
if (!seenPages.has(pageUri)) {
seenPages.add(pageUri);
deduped.push(r);
}
}
results = deduped.slice(0, maxResults).map((r) => ({
uri: r.id.replace(/#.*$/, ""),
title: (r.pageTitle as string) || (r.title as string) || r.id,
score: r.score,
}));
} else {
// Fallback: simple keyword matching if index unavailable
const keywords = query.toLowerCase().split(/\s+/);
const scored: Array<{ uri: string; title: string; score: number }> = [];
for (const doc of docCatalogue) {
let score = 0;
const nameLower = doc.name.toLowerCase();
const uriLower = doc.uri.toLowerCase();
for (const kw of keywords) {
if (nameLower.includes(kw)) score += 10;
if (uriLower.includes(kw)) score += 5;
}
if (score > 0) {
scored.push({ uri: doc.uri, title: doc.name, score });
}
}
scored.sort((a, b) => b.score - a.score);
results = scored.slice(0, maxResults);
}
// Extract snippets from the matching docs
const formatted = results.map((r, i) => {
let snippet = "";
const doc = docByUri.get(r.uri);
if (doc) {
try {
const content = fs.readFileSync(doc.filePath, "utf-8");
const contentLower = content.toLowerCase();
const keywords = query.toLowerCase().split(/\s+/);
const firstKw = keywords.find((kw) => contentLower.includes(kw));
if (firstKw) {
const idx = contentLower.indexOf(firstKw);
const start = Math.max(0, idx - 80);
const end = Math.min(content.length, idx + firstKw.length + 80);
snippet =
(start > 0 ? "..." : "") +
content.slice(start, end).trim() +
(end < content.length ? "..." : "");
}
} catch {
// skip snippet on read error
}
}
return `${i + 1}. **${r.title}** (${r.uri})\n ${snippet}`;
});
const text =
results.length === 0
? `No results found for "${query}".`
: formatted.join("\n\n");
return {
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text }],
},
};
}
function handleGetFullDocs(id: string | number | null): JsonRpcResponse {
const fullDocsPath = path.join(process.cwd(), "public", "llms-full.txt");
let content = "";
try {
content = fs.readFileSync(fullDocsPath, "utf-8");
} catch {
return {
jsonrpc: "2.0",
id,
error: {
code: -32603,
message: "Failed to read full documentation file",
},
};
}
return {
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: content }],
},
};
}
// ---------------------------------------------------------------------------
// Agent instruction tools
// ---------------------------------------------------------------------------
const PAGES_DIR = path.join(process.cwd(), "pages", "agent-instructions");
function readAgentPage(slug: string): string | null {
// Try generated markdown first, fall back to MDX source
const llmsPath = path.join(LLMS_DIR, "agent-instructions", `${slug}.md`);
if (fs.existsSync(llmsPath)) {
return fs.readFileSync(llmsPath, "utf-8");
}
const mdxPath = path.join(PAGES_DIR, `${slug}.mdx`);
if (fs.existsSync(mdxPath)) {
return fs.readFileSync(mdxPath, "utf-8");
}
return null;
}
// ---------------------------------------------------------------------------
// Notifications (no response needed)
// ---------------------------------------------------------------------------
const NOTIFICATION_METHODS = new Set([
"notifications/initialized",
"notifications/cancelled",
"notifications/progress",
]);
// ---------------------------------------------------------------------------
// Route JSON-RPC request to handler
// ---------------------------------------------------------------------------
function routeRequest(
rpcReq: JsonRpcRequest,
httpReq: NextApiRequest,
): JsonRpcResponse | null {
const { id, method, params } = rpcReq;
// Notifications have no id and expect no response
if (id === undefined || id === null) {
if (NOTIFICATION_METHODS.has(method)) return null;
// Unknown notification — ignore
return null;
}
// Track MCP usage
const trackProps: Record<string, unknown> = {};
if (method === "tools/call" && params?.name) {
trackProps.tool = params.name;
const args = params.arguments as Record<string, unknown> | undefined;
if (args?.query) trackProps.tool_query = args.query;
}
if (method === "resources/read" && params?.uri) {
trackProps.resource_uri = params.uri;
}
trackMcpEvent(httpReq, method, trackProps);
switch (method) {
case "initialize":
return handleInitialize(id);
case "resources/list":
return handleResourcesList(id);
case "resources/read":
return handleResourcesRead(id, params || {});
case "tools/list":
return handleToolsList(id);
case "tools/call":
return handleToolsCall(id, params || {});
case "ping":
return { jsonrpc: "2.0", id, result: {} };
default:
return {
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Method not found: ${method}` },
};
}
}
// ---------------------------------------------------------------------------
// Next.js API handler
// ---------------------------------------------------------------------------
export const config = {
// Disable body size limit and response size limit for SSE / large docs
api: { responseLimit: false },
};
export default function handler(
req: NextApiRequest,
res: NextApiResponse,
): void {
// CORS headers for cross-origin MCP clients
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Accept, Mcp-Session-Id",
);
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
if (req.method === "OPTIONS") {
res.status(204).end();
return;
}
// -----------------------------------------------------------------------
// GET — Streamable HTTP SSE endpoint for server-to-client notifications.
// mcp-remote establishes this connection first before sending POST.
// For a stateless server we just keep the stream open.
// -----------------------------------------------------------------------
if (req.method === "GET") {
const accept = (req.headers.accept || "").toLowerCase();
if (accept.includes("text/event-stream")) {
// SSE stream — required by MCP Streamable HTTP transport
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
});
// Send a keep-alive comment so the client knows the connection is alive
res.write(": connected\n\n");
// Keep the connection open; the client closes when it's done
const keepAlive = setInterval(() => {
res.write(": ping\n\n");
}, 15_000);
req.on("close", () => {
clearInterval(keepAlive);
res.end();
});
return;
}
// Plain GET returns server metadata (useful for browser discovery)
res.status(200).json({
name: SERVER_NAME,
version: SERVER_VERSION,
protocolVersion: PROTOCOL_VERSION,
description:
"MCP server for Hatchet documentation. Send JSON-RPC 2.0 POST requests to interact.",
});
return;
}
// -----------------------------------------------------------------------
// DELETE — session termination (no-op for stateless server)
// -----------------------------------------------------------------------
if (req.method === "DELETE") {
res.status(200).end();
return;
}
if (req.method !== "POST") {
res.status(405).json({ error: "Method not allowed" });
return;
}
// -----------------------------------------------------------------------
// POST — JSON-RPC 2.0 request handling
// -----------------------------------------------------------------------
const body = req.body;
// Handle batch requests (array of JSON-RPC messages)
if (Array.isArray(body)) {
const responses: JsonRpcResponse[] = [];
for (const item of body) {
const result = routeRequest(item as JsonRpcRequest, req);
if (result) responses.push(result);
}
if (responses.length === 0) {
res.status(204).end();
} else {
res.status(200).json(responses);
}
return;
}
// Single request
const result = routeRequest(body as JsonRpcRequest, req);
if (!result) {
// Notification — no response
res.status(204).end();
return;
}
res.status(200).json(result);
}