diff --git a/apps/backend/.env b/apps/backend/.env index 1cb85405db..fc0277d588 100644 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -115,3 +115,6 @@ STACK_STRIPE_SECRET_KEY=# enter your stripe api key STACK_STRIPE_WEBHOOK_SECRET=# enter your stripe webhook secret STACK_TELEGRAM_BOT_TOKEN= # enter you telegram bot token STACK_TELEGRAM_CHAT_ID=# enter your telegram chat id + +# Docs AI tool bundle +STACK_DOCS_INTERNAL_BASE_URL=# override the docs origin used by the backend's AI tool bundle to call the docs app's `/api/internal/docs-tools` endpoint. Defaults to http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}04 in dev, https://mcp.stack-auth.com in prod diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 90b16c8740..eb4b2dddac 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -77,6 +77,7 @@ STACK_OPENAI_API_KEY=mock_openai_api_key STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret STACK_OPENROUTER_API_KEY=FORWARD_TO_PRODUCTION +# STACK_DOCS_INTERNAL_BASE_URL=http://localhost:8104 # Email monitor configuration for tests STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:8101/handler/email-verification STACK_EMAIL_MONITOR_PROJECT_ID=internal diff --git a/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts b/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts index 3de02e2437..c578f77235 100644 --- a/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts +++ b/apps/backend/src/app/api/latest/ai/query/[mode]/route.ts @@ -118,7 +118,7 @@ export const POST = createSmartRouteHandler({ return { statusCode: 200, bodyType: "json" as const, - body: { content: contentBlocks }, + body: { content: contentBlocks, finalText: result.text }, }; } }, diff --git a/apps/backend/src/lib/ai/tools/docs.ts b/apps/backend/src/lib/ai/tools/docs.ts index fd23bf47f4..91f45ea104 100644 --- a/apps/backend/src/lib/ai/tools/docs.ts +++ b/apps/backend/src/lib/ai/tools/docs.ts @@ -1,21 +1,126 @@ -import { createMCPClient } from "@ai-sdk/mcp"; -import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; +import { tool } from "ai"; +import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; +import { captureError } from "@stackframe/stack-shared/dist/utils/errors"; +import { z } from "zod"; + +type DocsToolHttpResult = { + content?: Array<{ type: string, text?: string }>, + isError?: boolean, +}; + +function getDocsToolsBaseUrl(): string { + const fromEnv = getEnvVariable("STACK_DOCS_INTERNAL_BASE_URL", ""); + if (fromEnv !== "") { + return fromEnv.replace(/\/$/, ""); + } + if (getNodeEnvironment() === "development") { + const portPrefix = getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81"); + return `http://localhost:${portPrefix}04`; + } + return "https://mcp.stack-auth.com"; +} + +async function postDocsToolAction(action: Record): Promise { + const base = getDocsToolsBaseUrl(); + + const res = await fetch(`${base}/api/internal/docs-tools`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(action), + }); + + if (!res.ok) { + const errBody = await res.text(); + captureError("docs-tools-http-error", new Error(`Stack Auth docs tools error (${res.status}): ${errBody}`)); + return `Stack Auth docs tools error (${res.status}): ${errBody}`; + } + + const data = (await res.json()) as DocsToolHttpResult; + const text = data.content + ?.filter((c): c is { type: "text", text: string } => c.type === "text" && typeof c.text === "string") + .map((c) => c.text) + .join("\n") ?? ""; + + if (data.isError === true) { + return text || "Unknown docs tool error"; + } + + return text; +} /** - * Creates an MCP client connected to the Stack Auth documentation server. + * Documentation tools backed by the docs app's `/api/internal/docs-tools` endpoint. * - * In development: connects to local docs server at http://localhost:8126 - * In production: connects to production docs server at https://mcp.stack-auth.com + * The public MCP server at the same docs origin exposes only `ask_stack_auth`, which proxies to + * `/api/latest/ai/query/generate`; these tools avoid MCP recursion by calling the HTTP API directly. */ export async function createDocsTools() { - const mcpUrl = - getNodeEnvironment() === "development" - ? new URL("/api/internal/mcp", "http://localhost:8126") - : new URL("/api/internal/mcp", "https://mcp.stack-auth.com"); + return { + list_available_docs: tool({ + description: + "Use this tool to learn about what Stack Auth is, available documentation, and see if you can use it for what you're working on. It returns a list of all available Stack Auth Documentation pages.", + inputSchema: z.object({}), + execute: async () => { + return await postDocsToolAction({ action: "list_available_docs" }); + }, + }), - const stackAuthMcp = await createMCPClient({ - transport: { type: "http", url: mcpUrl.toString() }, - }); + search_docs: tool({ + description: + "Search through all Stack Auth documentation including API docs, guides, and examples. Returns ranked results with snippets and relevance scores.", + inputSchema: z.object({ + search_query: z.string().describe("The search query to find relevant documentation"), + result_limit: z.number().optional().describe("Maximum number of results to return (default: 50)"), + }), + execute: async ({ search_query, result_limit = 50 }) => { + return await postDocsToolAction({ + action: "search_docs", + search_query, + result_limit, + }); + }, + }), + + get_docs_by_id: tool({ + description: + "Use this tool to retrieve a specific Stack Auth Documentation page by its ID. It gives you the full content of the page so you can know exactly how to use specific Stack Auth APIs. Whenever using Stack Auth, you should always check the documentation first to have the most up-to-date information. When you write code using Stack Auth documentation you should reference the content you used in your comments.", + inputSchema: z.object({ + id: z.string(), + }), + execute: async ({ id }) => { + return await postDocsToolAction({ action: "get_docs_by_id", id }); + }, + }), + + get_stack_auth_setup_instructions: tool({ + description: + "Use this tool when the user wants to set up authentication in a new project. It provides step-by-step instructions for installing and configuring Stack Auth authentication.", + inputSchema: z.object({}), + execute: async () => { + return await postDocsToolAction({ action: "get_stack_auth_setup_instructions" }); + }, + }), + + search: tool({ + description: + "Search for Stack Auth documentation pages.\n\nUse this tool to find documentation pages that contain a specific keyword or phrase.", + inputSchema: z.object({ + query: z.string(), + }), + execute: async ({ query }) => { + return await postDocsToolAction({ action: "search", query }); + }, + }), - return await stackAuthMcp.tools(); + fetch: tool({ + description: + "Fetch a particular Stack Auth Documentation page by its ID.\n\nThis tool is identical to `get_docs_by_id`.", + inputSchema: z.object({ + id: z.string(), + }), + execute: async ({ id }) => { + return await postDocsToolAction({ action: "fetch", id }); + }, + }), + }; } diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index 5cbf4a426f..255e179af8 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -139,6 +139,8 @@ A: Update affected inline snapshots in `apps/e2e/tests/backend/endpoints/api/v1/ Q: How should `createOrUpdateProjectWithLegacyConfig` handle `onboardingStatus` for forward-compat checks? A: Only write `onboardingStatus` when the `Project.onboardingStatus` column exists (for example by checking `information_schema.columns` in-transaction) so current code can still run against older schemas where that column is absent. +Q: How does the Stack Auth docs MCP relate to the ask-chat API and doc tools? +A: The public MCP (`/api/internal/mcp` on the docs site) exposes only `ask_stack_auth`, which POSTs to `/api/latest/ai/query/generate` with `tools: ["docs"]` and `systemPrompt: "docs-ask-ai"`. The backend no longer loads doc tools via MCP; `createDocsTools()` calls the docs app `POST /api/internal/docs-tools` with typed actions (same behavior as before). Optional `STACK_INTERNAL_DOCS_TOOLS_SECRET` gates the internal route; `STACK_DOCS_INTERNAL_BASE_URL` overrides the docs origin for the backend. Q: What caused the March 19, 2026 QEMU local emulator deps startup regression? A: The QEMU runtime path regressed when it switched from mounting `docker/local-emulator/base.env` into the runtime ISO to mounting the generated hidden file `docker/local-emulator/.env.development` instead. In testing, the `.env.development` QEMU path left cold boot stuck with only PostgreSQL healthy, while restoring the runtime ISO back to `base.env` brought deps startup back to about 12-13 seconds. The env payloads were effectively the same, so the likely issue was the QEMU runtime bundle/path handling for `.env.development`, not the actual env values. Q: Where is the private sign-up risk engine generated entrypoint in backend now? diff --git a/docs/.env b/docs/.env new file mode 100644 index 0000000000..2102f16e4f --- /dev/null +++ b/docs/.env @@ -0,0 +1,6 @@ +# Basic +NEXT_PUBLIC_STACK_API_URL=# the base URL of Stack's backend/API +NEXT_PUBLIC_STACK_PROJECT_ID=# the project ID to use +NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=# publishable client key for the project +STACK_SECRET_SERVER_KEY=# secret server key for the project +STACK_OPENROUTER_API_KEY=# OpenRouter API key for AI-enabled chat \ No newline at end of file diff --git a/docs/content/docs/(guides)/others/mcp-setup.mdx b/docs/content/docs/(guides)/others/mcp-setup.mdx index 2521595b70..047818527f 100644 --- a/docs/content/docs/(guides)/others/mcp-setup.mdx +++ b/docs/content/docs/(guides)/others/mcp-setup.mdx @@ -10,6 +10,8 @@ title: MCP Setup Set up Stack Auth's Model Context Protocol (MCP) server to get intelligent code assistance in your development environment. +The MCP server exposes a single tool, **`ask_stack_auth`**, which sends your question to the same Stack Auth documentation AI used on [docs.stack-auth.com](https://docs.stack-auth.com). + Cursor @@ -231,10 +233,9 @@ by [![Hypr MCP](https://hyprmcp.com/hyprmcp_20px.svg)](https://hyprmcp.com/)`}]+)>/); - if (componentMatch) { - const props = componentMatch[1]; - const documentMatch = props.match(/document=\{"([^"]+)"\}/); - const operationsMatch = props.match(/operations=\{(\[[^\]]+\])\}/); - - if (documentMatch && operationsMatch) { - const specFile = documentMatch[1]; - const operations = operationsMatch[1]; - - try { - const specPath = specFile; - const specContent = await readFile(specPath, "utf-8"); - const spec = JSON.parse(specContent); - const parsedOps = JSON.parse(operations); - let apiDetails = ''; - - for (const op of parsedOps) { - const { path: opPath, method } = op; - const pathSpec = spec.paths?.[opPath]; - const methodSpec = pathSpec?.[method.toLowerCase()]; - - if (methodSpec) { - // Add human-readable summary first - const fullUrl = methodSpec['x-full-url'] || `https://api.stack-auth.com/api/v1${opPath}`; - - apiDetails += `\n## ${method.toUpperCase()} ${opPath}\n`; - apiDetails += `**Full URL:** ${fullUrl}\n`; - apiDetails += `**Summary:** ${methodSpec.summary || 'No summary available'}\n\n`; - - // Then include the complete OpenAPI spec with all examples and schemas - const endpointJson = { - [opPath]: { - [method.toLowerCase()]: methodSpec - } - }; - apiDetails += "**Complete API Specification:**\n```json\n"; - apiDetails += JSON.stringify(endpointJson, null, 2); - apiDetails += "\n```\n\n---\n"; - } - } - - const resultText = `Title: ${page.data.title}\nDescription: ${page.data.description || ''}\n\n${apiDetails}`; - - return { - content: [ - { - type: "text" as const, - text: resultText, - }, - ], - }; - } catch (specError) { - const errorText = `Title: ${page.data.title}\nDescription: ${page.data.description || ''}\nError reading OpenAPI spec: ${specError instanceof Error ? specError.message : "Unknown error"}`; - - return { - content: [ - { - type: "text" as const, - text: errorText, - }, - ], - }; - } - } - } - - // If no component match or missing props, return regular content - const fallbackText = `Title: ${page.data.title}\nDescription: ${page.data.description || ''}\nContent:\n${content}`; - - return { - content: [ - { - type: "text" as const, - text: fallbackText, - }, - ], - }; -} - -// Get pages from both main docs and API docs -const pages = source.getPages(); -const apiPages = apiSource.getPages(); - -// Filter out admin API pages from the MCP server -const filteredApiPages = apiPages.filter((page) => { - // Exclude admin API pages - they should not be accessible via MCP - return !page.url.startsWith('/api/admin/'); -}); - -const allPages = [...pages, ...filteredApiPages]; - -// Helper to extract actual API endpoint from page frontmatter -function getApiEndpointFromPage(page: typeof allPages[0]): string | null { - if (!page.url.startsWith('/api/') || page.url.startsWith('/api/webhooks/')) { - return null; - } - - // Check if the page data has _openapi metadata - const pageData = page.data as { _openapi?: { method?: string, route?: string } }; - - if (pageData._openapi && pageData._openapi.method && pageData._openapi.route) { - const endpoint = `${pageData._openapi.method.toUpperCase()} ${pageData._openapi.route}`; - return endpoint; - } - - return null; -} - -const pageSummaries = allPages - .filter((v) => { - return !(v.slugs[0] == "API-Reference"); - }) - .map((page) => - ` -Title: ${page.data.title} -Description: ${page.data.description} -ID: ${page.url} -`.trim() - ) - .join("\n"); - -async function getDocsById({ id }: { id: string }): Promise { - nodeClient?.capture({ - event: "get_docs_by_id", - properties: { id }, - distinctId: "mcp-handler", - }); - const page = allPages.find((page) => page.url === id); - if (!page) { - return { content: [{ type: "text", text: "Page not found." }] }; - } - // Check if this is an API page and handle OpenAPI spec extraction - const isApiPage = page.url.startsWith("/api/"); - - // Try primary path first, then fallback to docs/ prefix or api/ prefix - const filePath = `content/${page.file.path}`; - try { - const content = await readFile(filePath, "utf-8"); - - if (isApiPage && content.includes(" { server.tool( - "list_available_docs", - "Use this tool to learn about what Stack Auth is, available documentation, and see if you can use it for what you're working on. It returns a list of all available Stack Auth Documentation pages.", - {}, - async ({}) => { - nodeClient?.capture({ - event: "list_available_docs", - properties: {}, - distinctId: "mcp-handler", - }); - return { - content: [{ type: "text", text: pageSummaries }], - }; - }, - ); - server.tool( - "search_docs", - "Search through all Stack Auth documentation including API docs, guides, and examples. Returns ranked results with snippets and relevance scores.", + "ask_stack_auth", + "Ask the Stack Auth documentation assistant. Use this for any question about Stack Auth: setup, APIs, SDK usage, configuration, or troubleshooting. The assistant searches official documentation and answers with citations. Always set `reason` to a short explanation of why you are calling this tool (for product analytics and debugging).", { - search_query: z.string().describe("The search query to find relevant documentation"), - result_limit: z.number().optional().describe("Maximum number of results to return (default: 50)") + question: z.string().describe("The full question to ask about Stack Auth."), + reason: z + .string() + .min(1) + .describe( + "Why the agent invoked this tool (e.g. user asked about OAuth setup, need Stack Auth API headers). Used for analytics, not sent to the model.", + ), }, - async ({ search_query, result_limit = 50 }) => { + async ({ question, reason }) => { nodeClient?.capture({ - event: "search_docs", - properties: { search_query, result_limit }, + event: "ask_stack_auth_mcp", + properties: { question, reason }, distinctId: "mcp-handler", }); - type SearchResult = { - title: string, - description: string, - url: string, - score: number, - snippet: string, - type: 'api' | 'docs', - apiEndpoint?: string | null, - }; - - const results: SearchResult[] = []; - const queryLower = search_query.toLowerCase().trim(); - const queryWords = queryLower.split(/\s+/).filter(w => w.length > 0); - - // Search through all pages - for (const page of allPages) { - // Skip admin API endpoints - if (page.url.startsWith('/api/admin/')) { - continue; - } - - let score = 0; - const title = page.data.title || ''; - const description = page.data.description || ''; - const titleLower = title.toLowerCase(); - const descriptionLower = description.toLowerCase(); - - // Exact phrase match in title (highest priority) - if (titleLower.includes(queryLower)) { - if (titleLower === queryLower) { - score += 100; // Exact match - } else if (titleLower.startsWith(queryLower)) { - score += 80; // Starts with - } else { - score += 60; // Contains - } - } - - // Individual word matching in title - for (const word of queryWords) { - if (titleLower.includes(word)) { - score += 30; // Each word match - } - } - - // Exact phrase match in description - if (descriptionLower.includes(queryLower)) { - score += 40; - } - - // Individual word matching in description - for (const word of queryWords) { - if (descriptionLower.includes(word)) { - score += 15; // Each word match - } - } - // TOC/heading matching - for (const tocItem of page.data.toc) { - if (typeof tocItem.title === 'string') { - const tocTitleLower = tocItem.title.toLowerCase(); - // Exact phrase match - if (tocTitleLower.includes(queryLower)) { - score += 30; - } - // Individual word matching - for (const word of queryWords) { - if (tocTitleLower.includes(word)) { - score += 10; - } - } - } - } - - // Content matching (try to read the actual file) - try { - // Fix the file path - fumadocs page.file.path doesn't include 'api/' prefix - let filePath = `content/${page.file.path}`; - // If it's an API page and the path doesn't start with api/, add it - if (page.url.startsWith('/api/') && !page.file.path.startsWith('api/')) { - filePath = `content/api/${page.file.path}`; - } - const content = await readFile(filePath, "utf-8"); - const textContent = content - .replace(/^---[\s\S]*?---/, '') // Remove frontmatter - .replace(/<[^>]*>/g, ' ') // Remove JSX tags - .replace(/\{[^}]*\}/g, ' ') // Remove JSX expressions - .replace(/```[a-zA-Z]*\n/g, ' ') // Remove code block markers - .replace(/```/g, ' ') - .replace(/`([^`]*)`/g, '$1') // Remove inline code backticks - .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // Extract link text - .replace(/[#*_~]/g, '') // Remove markdown formatting - .replace(/\s+/g, ' ') - .trim(); - - const textContentLower = textContent.toLowerCase(); - - // Exact phrase match in content - let hasContentMatch = false; - if (textContentLower.includes(queryLower)) { - score += 20; - hasContentMatch = true; - } - - // Individual word matching in content - for (const word of queryWords) { - if (textContentLower.includes(word)) { - score += 5; // Each word match - hasContentMatch = true; - } - } - - if (hasContentMatch && queryWords.length > 0) { - // Find snippet around the first query word match - const firstWord = queryWords[0]; - const matchIndex = textContentLower.indexOf(firstWord); - const start = Math.max(0, matchIndex - 50); - const end = Math.min(textContent.length, matchIndex + 100); - const snippet = textContent.slice(start, end); - - // For API pages, try to extract the actual endpoint - const apiEndpoint = page.url.startsWith('/api/') ? getApiEndpointFromPage(page) : null; - - results.push({ - title, - description, - url: page.url, - score, - snippet: `...${snippet}...`, - type: page.url.startsWith('/api/') ? 'api' : 'docs', - apiEndpoint - }); - } else if (score > 0) { - // Add without snippet if title/description matched - const apiEndpoint = page.url.startsWith('/api/') ? getApiEndpointFromPage(page) : null; - - results.push({ - title, - description, - url: page.url, - score, - snippet: description || title, - type: page.url.startsWith('/api/') ? 'api' : 'docs', - apiEndpoint - }); - } - } catch (error) { - // If file reading fails but we have title/description matches - if (score > 0) { - const apiEndpoint = page.url.startsWith('/api/') ? getApiEndpointFromPage(page) : null; - - results.push({ - title, - description, - url: page.url, - score, - snippet: description || title, - type: page.url.startsWith('/api/') ? 'api' : 'docs', - apiEndpoint - }); - } - } + const apiBase = process.env.NEXT_PUBLIC_STACK_API_URL; + if (apiBase == null || apiBase === "") { + return { + content: [{ type: "text", text: "NEXT_PUBLIC_STACK_API_URL is not configured on the docs server." }], + isError: true, + }; } - // Sort by score (highest first) and limit results - const sortedResults = results - .sort((a, b) => b.score - a.score) - .slice(0, result_limit); - - const searchResultText = sortedResults.length > 0 - ? sortedResults.map(result => { - let text = `Title: ${result.title}\nDescription: ${result.description}\n`; - - if (result.apiEndpoint) { - text += `API Endpoint: ${result.apiEndpoint}\n`; - } - - text += `Documentation URL: ${result.url}\nType: ${result.type}\nScore: ${result.score}\nSnippet: ${result.snippet}\n`; - - return text; - }).join('\n---\n') - : `No results found for "${search_query}"`; - - return { - content: [{ type: "text", text: searchResultText }], - }; - } - ); - server.tool( - "get_docs_by_id", - "Use this tool to retrieve a specific Stack Auth Documentation page by its ID. It gives you the full content of the page so you can know exactly how to use specific Stack Auth APIs. Whenever using Stack Auth, you should always check the documentation first to have the most up-to-date information. When you write code using Stack Auth documentation you should reference the content you used in your comments.", - { id: z.string() }, - getDocsById, - ); - server.tool( - "get_stack_auth_setup_instructions", - "Use this tool when the user wants to set up authentication in a new project. It provides step-by-step instructions for installing and configuring Stack Auth authentication.", - {}, - async () => { - nodeClient?.capture({ - event: "get_stack_auth_setup_instructions", - properties: {}, - distinctId: "mcp-handler", + const url = `${apiBase.replace(/\/$/, "")}/api/latest/ai/query/generate`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + quality: "smart", + speed: "fast", + tools: ["docs"], + systemPrompt: "docs-ask-ai", + messages: [{ role: "user", content: question }], + }), }); - try { - const instructionsFromCurrentFile = "content/setup-instructions.md"; - const instructions = await readFile(instructionsFromCurrentFile, "utf-8"); - - return { - content: [ - { - type: "text" as const, - text: instructions, - }, - ], - }; - } catch (error) { + if (!res.ok) { + const errText = await res.text(); return { - content: [ - { - type: "text" as const, - text: `Error reading setup instructions: ${error instanceof Error ? error.message : "Unknown error"}`, - }, - ], + content: [{ type: "text", text: `Stack Auth AI error (${res.status}): ${errText}` }], isError: true, }; } - }, - ); - // Search tool for ChatGPT deep research - // Reference: https://platform.openai.com/docs/mcp#search-tool - server.tool( - "search", - "Search for Stack Auth documentation pages.\n\nUse this tool to find documentation pages that contain a specific keyword or phrase.", - { query: z.string() }, - async ({ query }) => { - nodeClient?.capture({ - event: "search", - properties: { query }, - distinctId: "mcp-handler", - }); + const body = (await res.json()) as { + finalText?: string, + content?: Array<{ type: string, text?: string }>, + }; + + const text = + body.finalText ?? + body.content + ?.filter((c): c is { type: "text", text: string } => c.type === "text" && typeof c.text === "string") + .map((c) => c.text) + .join("\n\n") ?? + ""; - const q = query.toLowerCase(); - const results = allPages - .filter( - (page) => - page.data.title.toLowerCase().includes(q) || - page.data.description?.toLowerCase().includes(q), - ) - .map((page) => ({ - id: page.url, - title: page.data.title, - url: page.url, - })); return { - content: [ - { - type: "text", - text: JSON.stringify({ results }), - }, - ], + content: [{ type: "text", text: text.length > 0 ? text : "(empty response)" }], }; }, ); - - // Fetch tool for ChatGPT deep research - // Reference: https://platform.openai.com/docs/mcp#fetch-tool - server.tool( - "fetch", - "Fetch a particular Stack Auth Documentation page by its ID.\n\nThis tool is identical to `get_docs_by_id`.", - { id: z.string() }, - getDocsById, - ); }, { capabilities: { tools: { - listAvailableDocs: { - description: - "Use this tool to learn about what Stack Auth is, available documentation, and see if you can use it for what you're working on. It returns a list of all available Stack Auth Documentation pages.", - }, - getDocById: { + ask_stack_auth: { description: - "Use this tool to retrieve a specific Stack Auth Documentation page by its ID. It gives you the full content of the page so you can know exactly how to use specific Stack Auth APIs. Whenever using Stack Auth, you should always check the documentation first to have the most up-to-date information. When you write code using Stack Auth documentation you should reference the content you used in your comments.", + "Ask the Stack Auth documentation assistant any question about Stack Auth (setup, APIs, SDKs, configuration, troubleshooting).", parameters: { type: "object", properties: { - id: { + question: { type: "string", - description: "The ID of the documentation page to retrieve.", + description: "The full question to ask about Stack Auth.", }, - }, - required: ["id"], - }, - }, - getStackAuthSetupInstructions: { - description: - "Use this tool when the user wants to set up Stack Auth in a new project. It provides step-by-step instructions for installing and configuring Stack Auth authentication, including environment setup, file scaffolding, and verification steps.", - parameters: { - type: "object", - properties: {}, - required: [], - }, - }, - search: { - description: - "Search for Stack Auth documentation pages.\n\nUse this tool to find documentation pages that contain a specific keyword or phrase.", - parameters: { - type: "object", - properties: { - query: { - type: "string", - description: "The search query to use.", - }, - }, - required: ["query"], - }, - }, - fetch: { - description: - "Fetch a particular Stack Auth Documentation page by its ID.\n\nThis tool is identical to `get_docs_by_id`.", - parameters: { - type: "object", - properties: { - id: { + reason: { type: "string", - description: "The ID of the documentation page to retrieve.", + description: + "Why the agent invoked this tool (for analytics and debugging). Not sent to the documentation model.", }, }, - required: ["id"], + required: ["question", "reason"], }, }, }, @@ -605,7 +103,7 @@ const handler = createMcpHandler( { basePath: "/api/internal", verboseLogs: true, - maxDuration: 60, + maxDuration: 120, } ); diff --git a/docs/src/app/api/internal/[transport]/setup-instructions.md b/docs/src/app/api/internal/[transport]/setup-instructions.md index ad89486a3a..f7a4a2d625 100644 --- a/docs/src/app/api/internal/[transport]/setup-instructions.md +++ b/docs/src/app/api/internal/[transport]/setup-instructions.md @@ -20,8 +20,7 @@ Before proceeding, you MUST identify the project framework: **IMPORTANT**: Only proceed with the installation if you can clearly identify the project as either Next.js or React. ### 1) Run the Stack Auth initializer -- Use the `stack-auth` MCP server. -- Call the tool or run the command: +- Use the `stack-auth` MCP server (`ask_stack_auth` tool), or run the command: - **For Next.js projects**: ```bash npx @stackframe/stack-cli@latest init diff --git a/docs/src/app/api/internal/docs-tools/route.ts b/docs/src/app/api/internal/docs-tools/route.ts new file mode 100644 index 0000000000..783ba1f671 --- /dev/null +++ b/docs/src/app/api/internal/docs-tools/route.ts @@ -0,0 +1,43 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { type DocsToolAction, executeDocsToolAction } from "@/lib/docs-tools-operations"; + +const bodySchema: z.ZodType = z.discriminatedUnion("action", [ + z.object({ action: z.literal("list_available_docs") }), + z.object({ + action: z.literal("search_docs"), + search_query: z.string(), + result_limit: z.number().optional(), + }), + z.object({ action: z.literal("get_docs_by_id"), id: z.string() }), + z.object({ action: z.literal("get_stack_auth_setup_instructions") }), + z.object({ action: z.literal("search"), query: z.string() }), + z.object({ action: z.literal("fetch"), id: z.string() }), +]); + +export async function POST(req: NextRequest) { + let json: unknown; + try { + json = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = bodySchema.safeParse(json); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid body", details: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const result = await executeDocsToolAction(parsed.data); + return NextResponse.json(result); +} + +export async function GET() { + return NextResponse.json( + { error: "Use POST with a DocsToolAction body" }, + { status: 405, headers: { Allow: "POST" } }, + ); +} diff --git a/docs/src/app/api/search/route.ts b/docs/src/app/api/search/route.ts index 9159c39c82..92bcb115e0 100644 --- a/docs/src/app/api/search/route.ts +++ b/docs/src/app/api/search/route.ts @@ -8,92 +8,30 @@ type SearchResult = { title?: string, }; -// Helper function to call MCP server -async function callMcpServer(search_query: string): Promise { +// Helper: same search implementation as MCP / backend docs tools (internal HTTP API) +async function callDocsToolsSearch(search_query: string, requestOrigin: string): Promise { try { - // Use localhost during development, production URL otherwise - const mcpUrl = process.env.NODE_ENV === 'development' - ? 'http://localhost:8104/api/internal/mcp' - : 'https://mcp.stack-auth.com/api/internal/mcp'; - - console.log(`Calling MCP server at: ${mcpUrl}`); - - const response = await fetch(mcpUrl, { + const response = await fetch(`${requestOrigin}/api/internal/docs-tools`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Accept': 'application/json, text/event-stream', }, body: JSON.stringify({ - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'search_docs', - arguments: { search_query, result_limit: 20 }, - }, - id: Date.now(), + action: 'search_docs', + search_query, + result_limit: 20, }), }); if (!response.ok) { const errorText = await response.text(); - console.error(`MCP server error (${response.status}):`, errorText); - throw new Error(`MCP server error: ${response.status} - ${errorText.substring(0, 200)}`); - } - - // Parse Server-Sent Events format response - // Read the stream until we get the data event (don't wait for connection to close) - if (!response.body) { - throw new Error('No response body'); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let jsonData = null; - - try { - while (true) { - const { done, value } = await reader.read(); - - if (value) { - buffer += decoder.decode(value, { stream: true }); - - // Look for complete data: lines in the buffer - const lines = buffer.split('\n'); - - for (const line of lines) { - if (line.startsWith('data: ')) { - try { - jsonData = JSON.parse(line.substring(6)); - // Found our data, we can stop reading - await reader.cancel(); - break; - } catch (e) { - // Continue looking for valid JSON data - } - } - } - - if (jsonData) break; - } - - if (done) break; - } - } finally { - reader.releaseLock(); + console.error(`docs-tools error (${response.status}):`, errorText); + throw new Error(`docs-tools error: ${response.status} - ${errorText.substring(0, 200)}`); } - if (!jsonData) { - throw new Error('Invalid MCP response format'); - } - - if (jsonData.error) { - throw new Error(jsonData.error.message || 'MCP search failed'); - } + const jsonData = (await response.json()) as { content?: Array<{ type: string, text?: string }> }; - // Parse the search results from the text response - const searchResultText = jsonData.result?.content?.[0]?.text || ''; + const searchResultText = jsonData.content?.[0]?.text || ''; if (searchResultText.includes('No results found')) { return []; } @@ -139,7 +77,7 @@ async function callMcpServer(search_query: string): Promise { return results; } catch (error) { - console.error('MCP server call failed:', error); + console.error('docs-tools search failed:', error); // Fallback to empty results return []; } @@ -167,20 +105,20 @@ export async function GET(request: NextRequest) { } try { - // Call MCP server for search results - const results = await callMcpServer(search_query); + const origin = new URL(request.url).origin; + const results = await callDocsToolsSearch(search_query, origin); - console.log(`Found ${results.length} search results from MCP server for "${search_query}"`); + console.log(`Found ${results.length} search results from docs-tools for "${search_query}"`); // Filter out admin API endpoints as an additional safety measure const filteredResults = results.filter(result => !result.url.startsWith('/api/admin')); - // Sort by platform priority since MCP server already handles relevance + // Sort by platform priority since docs-tools already handles relevance const sortedResults = filteredResults.sort((a, b) => { return getPlatformPriority(b.url) - getPlatformPriority(a.url); }); - console.log(`\n=== MCP SEARCH RESULTS FOR "${search_query}" ===`); + console.log(`\n=== DOCS SEARCH RESULTS FOR "${search_query}" ===`); sortedResults.slice(0, 10).forEach((result, i) => { const priority = getPlatformPriority(result.url); console.log(`${i + 1}. "${result.content}" (${result.type}) - Priority: ${priority} - URL: ${result.url}`); diff --git a/docs/src/app/mcp-browser/page.tsx b/docs/src/app/mcp-browser/page.tsx index 474205f7ab..f1364da3e8 100644 --- a/docs/src/app/mcp-browser/page.tsx +++ b/docs/src/app/mcp-browser/page.tsx @@ -33,56 +33,21 @@ export default function McpBrowserPage() { const [docLoading, setDocLoading] = useState(false); const [error, setError] = useState(null); - // Function to call MCP tools - const callMcpTool = async (toolName: string, args: Record = {}) => { - const response = await fetch('/api/internal/mcp', { + /** Same doc operations as the MCP-exposed `ask_stack_auth` backend path; list/get use the internal JSON API. */ + const callDocsTool = async (body: Record) => { + const response = await fetch('/api/internal/docs-tools', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Accept': 'application/json, text/event-stream', }, - body: JSON.stringify({ - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: toolName, - arguments: args, - }, - id: Date.now(), - }), + body: JSON.stringify(body), }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } - // Parse Server-Sent Events format response - const text = await response.text(); - - // Look for the data line in the SSE response - const lines = text.split('\n'); - let jsonData = null; - - for (const line of lines) { - if (line.startsWith('data: ')) { - try { - jsonData = JSON.parse(line.substring(6)); - break; - } catch (e) { - // Continue looking for valid JSON data - } - } - } - - if (!jsonData) { - throw new Error('Invalid MCP response format'); - } - - if (jsonData.error) { - throw new Error(jsonData.error.message || 'MCP tool call failed'); - } - - return jsonData.result; + return await response.json() as { content: Array<{ type: string, text?: string }> }; }; // Parse the doc summaries from the text response @@ -119,7 +84,7 @@ export default function McpBrowserPage() { const loadDocs = async () => { try { setLoading(true); - const result = await callMcpTool('list_available_docs'); + const result = await callDocsTool({ action: 'list_available_docs' }); const textContent = result.content[0]?.text || ''; const parsedDocs = parseDocSummaries(textContent); setDocs(parsedDocs); @@ -140,7 +105,7 @@ export default function McpBrowserPage() { const loadDoc = async (docId: string) => { try { setDocLoading(true); - const result = await callMcpTool('get_docs_by_id', { id: docId }); + const result = await callDocsTool({ action: 'get_docs_by_id', id: docId }); const textContent = result.content[0]?.text || ''; // Parse the response which includes title, description, and content @@ -235,7 +200,8 @@ export default function McpBrowserPage() {

- Browse Stack Auth documentation through the Model Context Protocol server. + Browse Stack Auth documentation using the same sources as the public MCP server (the MCP surface exposes only the{' '} + ask_stack_auth tool; this page uses the internal docs API for listing and viewing pages). Found {docs.length} documentation pages.

diff --git a/docs/src/lib/docs-tools-operations.ts b/docs/src/lib/docs-tools-operations.ts new file mode 100644 index 0000000000..601f865eaf --- /dev/null +++ b/docs/src/lib/docs-tools-operations.ts @@ -0,0 +1,491 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { PostHog } from "posthog-node"; +import { apiSource, source } from "../../lib/source"; + +const nodeClient = process.env.NEXT_PUBLIC_POSTHOG_KEY + ? new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY) + : null; + +async function extractOpenApiDetails( + content: string, + page: { data: { title: string, description?: string } }, +): Promise { + const componentMatch = content.match(/]+)>/); + if (componentMatch) { + const props = componentMatch[1]; + const documentMatch = props.match(/document=\{"([^"]+)"\}/); + const operationsMatch = props.match(/operations=\{(\[[^\]]+\])\}/); + + if (documentMatch && operationsMatch) { + const specFile = documentMatch[1]; + const operations = operationsMatch[1]; + + try { + const specPath = specFile; + const specContent = await readFile(specPath, "utf-8"); + const spec = JSON.parse(specContent); + const parsedOps = JSON.parse(operations); + let apiDetails = ''; + + for (const op of parsedOps) { + const { path: opPath, method } = op; + const pathSpec = spec.paths?.[opPath]; + const methodSpec = pathSpec?.[method.toLowerCase()]; + + if (methodSpec) { + const fullUrl = methodSpec['x-full-url'] || `https://api.stack-auth.com/api/v1${opPath}`; + + apiDetails += `\n## ${method.toUpperCase()} ${opPath}\n`; + apiDetails += `**Full URL:** ${fullUrl}\n`; + apiDetails += `**Summary:** ${methodSpec.summary || 'No summary available'}\n\n`; + + const endpointJson = { + [opPath]: { + [method.toLowerCase()]: methodSpec + } + }; + apiDetails += "**Complete API Specification:**\n```json\n"; + apiDetails += JSON.stringify(endpointJson, null, 2); + apiDetails += "\n```\n\n---\n"; + } + } + + const resultText = `Title: ${page.data.title}\nDescription: ${page.data.description || ''}\n\n${apiDetails}`; + + return { + content: [ + { + type: "text" as const, + text: resultText, + }, + ], + }; + } catch (specError) { + const errorText = `Title: ${page.data.title}\nDescription: ${page.data.description || ''}\nError reading OpenAPI spec: ${specError instanceof Error ? specError.message : "Unknown error"}`; + + return { + content: [ + { + type: "text" as const, + text: errorText, + }, + ], + }; + } + } + } + + const fallbackText = `Title: ${page.data.title}\nDescription: ${page.data.description || ''}\nContent:\n${content}`; + + return { + content: [ + { + type: "text" as const, + text: fallbackText, + }, + ], + }; +} + +const pages = source.getPages(); +const apiPages = apiSource.getPages(); + +const filteredApiPages = apiPages.filter((page) => { + return !page.url.startsWith('/api/admin/'); +}); + +const allPages = [...pages, ...filteredApiPages]; + +function getApiEndpointFromPage(page: typeof allPages[0]): string | null { + if (!page.url.startsWith('/api/') || page.url.startsWith('/api/webhooks/')) { + return null; + } + + const pageData = page.data as { _openapi?: { method?: string, route?: string } }; + + if (pageData._openapi && pageData._openapi.method && pageData._openapi.route) { + const endpoint = `${pageData._openapi.method.toUpperCase()} ${pageData._openapi.route}`; + return endpoint; + } + + return null; +} + +const pageSummaries = allPages + .filter((v) => { + return !(v.slugs[0] == "API-Reference"); + }) + .map((page) => + ` +Title: ${page.data.title} +Description: ${page.data.description} +ID: ${page.url} +`.trim() + ) + .join("\n"); + +async function getDocsByIdImpl({ id }: { id: string }): Promise { + nodeClient?.capture({ + event: "get_docs_by_id", + properties: { id }, + distinctId: "mcp-handler", + }); + const page = allPages.find((p) => p.url === id); + if (!page) { + return { content: [{ type: "text", text: "Page not found." }] }; + } + const isApiPage = page.url.startsWith("/api/"); + + const filePath = `content/${page.file.path}`; + try { + const content = await readFile(filePath, "utf-8"); + + if (isApiPage && content.includes(" { + nodeClient?.capture({ + event: "search_docs", + properties: { search_query, result_limit }, + distinctId: "mcp-handler", + }); + + const results: SearchResult[] = []; + const queryLower = search_query.toLowerCase().trim(); + const queryWords = queryLower.split(/\s+/).filter(w => w.length > 0); + + for (const page of allPages) { + if (page.url.startsWith('/api/admin/')) { + continue; + } + + let score = 0; + const title = page.data.title || ''; + const description = page.data.description || ''; + const titleLower = title.toLowerCase(); + const descriptionLower = description.toLowerCase(); + + if (titleLower.includes(queryLower)) { + if (titleLower === queryLower) { + score += 100; + } else if (titleLower.startsWith(queryLower)) { + score += 80; + } else { + score += 60; + } + } + + for (const word of queryWords) { + if (titleLower.includes(word)) { + score += 30; + } + } + + if (descriptionLower.includes(queryLower)) { + score += 40; + } + + for (const word of queryWords) { + if (descriptionLower.includes(word)) { + score += 15; + } + } + for (const tocItem of page.data.toc) { + if (typeof tocItem.title === 'string') { + const tocTitleLower = tocItem.title.toLowerCase(); + if (tocTitleLower.includes(queryLower)) { + score += 30; + } + for (const word of queryWords) { + if (tocTitleLower.includes(word)) { + score += 10; + } + } + } + } + + try { + let filePath = `content/${page.file.path}`; + if (page.url.startsWith('/api/') && !page.file.path.startsWith('api/')) { + filePath = `content/api/${page.file.path}`; + } + const content = await readFile(filePath, "utf-8"); + const textContent = content + .replace(/^---[\s\S]*?---/, '') + .replace(/<[^>]*>/g, ' ') + .replace(/\{[^}]*\}/g, ' ') + .replace(/```[a-zA-Z]*\n/g, ' ') + .replace(/```/g, ' ') + .replace(/`([^`]*)`/g, '$1') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[#*_~]/g, '') + .replace(/\s+/g, ' ') + .trim(); + + const textContentLower = textContent.toLowerCase(); + + let hasContentMatch = false; + if (textContentLower.includes(queryLower)) { + score += 20; + hasContentMatch = true; + } + + for (const word of queryWords) { + if (textContentLower.includes(word)) { + score += 5; + hasContentMatch = true; + } + } + + if (hasContentMatch && queryWords.length > 0) { + const firstWord = queryWords[0]; + const matchIndex = textContentLower.indexOf(firstWord); + const start = Math.max(0, matchIndex - 50); + const end = Math.min(textContent.length, matchIndex + 100); + const snippet = textContent.slice(start, end); + + const apiEndpoint = page.url.startsWith('/api/') ? getApiEndpointFromPage(page) : null; + + results.push({ + title, + description, + url: page.url, + score, + snippet: `...${snippet}...`, + type: page.url.startsWith('/api/') ? 'api' : 'docs', + apiEndpoint + }); + } else if (score > 0) { + const apiEndpoint = page.url.startsWith('/api/') ? getApiEndpointFromPage(page) : null; + + results.push({ + title, + description, + url: page.url, + score, + snippet: description || title, + type: page.url.startsWith('/api/') ? 'api' : 'docs', + apiEndpoint + }); + } + } catch { + if (score > 0) { + const apiEndpoint = page.url.startsWith('/api/') ? getApiEndpointFromPage(page) : null; + + results.push({ + title, + description, + url: page.url, + score, + snippet: description || title, + type: page.url.startsWith('/api/') ? 'api' : 'docs', + apiEndpoint + }); + } + } + } + + const sortedResults = results + .sort((a, b) => b.score - a.score) + .slice(0, result_limit); + + const searchResultText = sortedResults.length > 0 + ? sortedResults.map(result => { + let text = `Title: ${result.title}\nDescription: ${result.description}\n`; + + if (result.apiEndpoint) { + text += `API Endpoint: ${result.apiEndpoint}\n`; + } + + text += `Documentation URL: ${result.url}\nType: ${result.type}\nScore: ${result.score}\nSnippet: ${result.snippet}\n`; + + return text; + }).join('\n---\n') + : `No results found for "${search_query}"`; + + return { + content: [{ type: "text", text: searchResultText }], + }; +} + +export type DocsToolAction = + | { action: "list_available_docs" } + | { action: "search_docs", search_query: string, result_limit?: number } + | { action: "get_docs_by_id", id: string } + | { action: "get_stack_auth_setup_instructions" } + | { action: "search", query: string } + | { action: "fetch", id: string }; + +export async function executeDocsToolAction(input: DocsToolAction): Promise { + switch (input.action) { + case "list_available_docs": { + nodeClient?.capture({ + event: "list_available_docs", + properties: {}, + distinctId: "mcp-handler", + }); + return { + content: [{ type: "text", text: pageSummaries }], + }; + } + case "search_docs": { + const limit = input.result_limit ?? 50; + return await searchDocsImpl(input.search_query, limit); + } + case "get_docs_by_id": { + return await getDocsByIdImpl({ id: input.id }); + } + case "get_stack_auth_setup_instructions": { + nodeClient?.capture({ + event: "get_stack_auth_setup_instructions", + properties: {}, + distinctId: "mcp-handler", + }); + + try { + const instructionsPath = path.join( + process.cwd(), + "src", + "app", + "api", + "internal", + "[transport]", + "setup-instructions.md", + ); + const instructions = await readFile(instructionsPath, "utf-8"); + + return { + content: [ + { + type: "text" as const, + text: instructions, + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text" as const, + text: `Error reading setup instructions: ${error instanceof Error ? error.message : "Unknown error"}`, + }, + ], + isError: true, + }; + } + } + case "search": { + nodeClient?.capture({ + event: "search", + properties: { query: input.query }, + distinctId: "mcp-handler", + }); + + const q = input.query.toLowerCase(); + const results = allPages + .filter( + (page) => + page.data.title.toLowerCase().includes(q) || + page.data.description?.toLowerCase().includes(q), + ) + .map((page) => ({ + id: page.url, + title: page.data.title, + url: page.url, + })); + return { + content: [ + { + type: "text", + text: JSON.stringify({ results }), + }, + ], + }; + } + case "fetch": { + return await getDocsByIdImpl({ id: input.id }); + } + } +}