Sync suggestion branch with base branch

This commit is contained in:
promptless[bot] 2026-04-12 23:33:22 +00:00
commit 4c53ac2d28
13 changed files with 760 additions and 707 deletions

View File

@ -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

View File

@ -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

View File

@ -118,7 +118,7 @@ export const POST = createSmartRouteHandler({
return {
statusCode: 200,
bodyType: "json" as const,
body: { content: contentBlocks },
body: { content: contentBlocks, finalText: result.text },
};
}
},

View File

@ -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";
/**
* Creates an MCP client connected to the Stack Auth documentation server.
*
* In development: connects to local docs server at http://localhost:8126
* In production: connects to production docs server at https://mcp.stack-auth.com
*/
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");
type DocsToolHttpResult = {
content?: Array<{ type: string, text?: string }>,
isError?: boolean,
};
const stackAuthMcp = await createMCPClient({
transport: { type: "http", url: mcpUrl.toString() },
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<string, unknown>): Promise<string> {
const base = getDocsToolsBaseUrl();
const res = await fetch(`${base}/api/internal/docs-tools`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(action),
});
return await stackAuthMcp.tools();
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;
}
/**
* Documentation tools backed by the docs app's `/api/internal/docs-tools` endpoint.
*
* 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() {
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" });
},
}),
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 });
},
}),
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 });
},
}),
};
}

View File

@ -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?

6
docs/.env Normal file
View File

@ -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

View File

@ -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).
<Tabs defaultValue="cursor">
<TabsList>
<TabsTrigger value="cursor">Cursor</TabsTrigger>
@ -231,10 +233,9 @@ by [![Hypr MCP](https://hyprmcp.com/hyprmcp_20px.svg)](https://hyprmcp.com/)`}</
## Features
The Stack Auth MCP server provides:
The Stack Auth MCP server exposes **`ask_stack_auth`**, which answers questions using live documentation retrieval and the docs-site AI assistant. It can help with:
- **Authentication Flow Assistance**: Get help implementing sign-in, sign-up, and user management
- **API Documentation**: Access Stack Auth API documentation and examples
- **Code Generation**: Generate boilerplate code for common authentication patterns
- **Best Practices**: Receive guidance on security best practices and implementation patterns
- **Authentication flows**: Sign-in, sign-up, and user management
- **APIs and SDKs**: Endpoints, examples, and framework integration
- **Best practices**: Security and configuration guidance

View File

@ -1,602 +1,100 @@
import { createMcpHandler } from "@vercel/mcp-adapter";
import { readFile } from "node:fs/promises";
import { z } from "zod";
import { apiSource, source } from "../../../../../lib/source";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { PostHog } from "posthog-node";
import { z } from "zod";
const nodeClient = process.env.NEXT_PUBLIC_POSTHOG_KEY
? new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY)
: null;
// Helper function to extract OpenAPI details from Enhanced API Page content
async function extractOpenApiDetails(
content: string,
page: { data: { title: string, description?: string } },
) {
const componentMatch = content.match(/<EnhancedAPIPage\s+([^>]+)>/);
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<CallToolResult> {
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("<EnhancedAPIPage")) {
// Extract OpenAPI information from API pages
try {
return await extractOpenApiDetails(content, page);
} catch {
return {
content: [
{
type: "text",
text: `Title: ${page.data.title}\nDescription: ${page.data.description}\nContent:\n${content}`,
},
],
};
}
} else {
// Regular doc page - return content as before
return {
content: [
{
type: "text",
text: `Title: ${page.data.title}\nDescription: ${page.data.description}\nContent:\n${content}`,
},
],
};
}
} catch {
// Try alternative paths
const altPaths = [
`content/docs/${page.file.path}`,
`content/api/${page.file.path}`,
];
for (const altPath of altPaths) {
try {
const content = await readFile(altPath, "utf-8");
if (isApiPage && content.includes("<EnhancedAPIPage")) {
// Same OpenAPI extraction logic for alternative path
try {
return await extractOpenApiDetails(content, page);
} catch {
// If parsing fails, return the raw content
return {
content: [
{
type: "text",
text: `Title: ${page.data.title}\nDescription: ${page.data.description}\nContent:\n${content}`,
},
],
};
}
} else {
return {
content: [
{
type: "text",
text: `Title: ${page.data.title}\nDescription: ${page.data.description}\nContent:\n${content}`,
},
],
};
}
} catch {
// Continue to next path
continue;
}
}
// If all paths fail
return {
content: [
{
type: "text",
text: `Title: ${page.data.title}\nDescription: ${page.data.description}\nError: Could not read file at any of the attempted paths: ${filePath}, ${altPaths.join(", ")}`,
},
],
isError: true,
};
}
}
const handler = createMcpHandler(
async (server) => {
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
});
}
}
}
// 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",
});
try {
const instructionsFromCurrentFile = "content/setup-instructions.md";
const instructions = await readFile(instructionsFromCurrentFile, "utf-8");
const apiBase = process.env.NEXT_PUBLIC_STACK_API_URL;
if (apiBase == null || apiBase === "") {
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"}`,
},
],
content: [{ type: "text", text: "NEXT_PUBLIC_STACK_API_URL is not configured on the docs server." }],
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 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 }],
}),
});
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,
}));
if (!res.ok) {
const errText = await res.text();
return {
content: [{ type: "text", text: `Stack Auth AI error (${res.status}): ${errText}` }],
isError: true,
};
}
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") ??
"";
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: {
ask_stack_auth: {
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: {
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.",
},
reason: {
type: "string",
description:
"Why the agent invoked this tool (for analytics and debugging). Not sent to the documentation model.",
},
},
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: {
type: "string",
description: "The ID of the documentation page to retrieve.",
},
},
required: ["id"],
required: ["question", "reason"],
},
},
},
@ -605,7 +103,7 @@ const handler = createMcpHandler(
{
basePath: "/api/internal",
verboseLogs: true,
maxDuration: 60,
maxDuration: 120,
}
);

View File

@ -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

View File

@ -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<DocsToolAction> = 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" } },
);
}

View File

@ -8,92 +8,30 @@ type SearchResult = {
title?: string,
};
// Helper function to call MCP server
async function callMcpServer(search_query: string): Promise<SearchResult[]> {
// Helper: same search implementation as MCP / backend docs tools (internal HTTP API)
async function callDocsToolsSearch(search_query: string, requestOrigin: string): Promise<SearchResult[]> {
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)}`);
console.error(`docs-tools error (${response.status}):`, errorText);
throw new Error(`docs-tools 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 jsonData = (await response.json()) as { content?: Array<{ type: string, text?: string }> };
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();
}
if (!jsonData) {
throw new Error('Invalid MCP response format');
}
if (jsonData.error) {
throw new Error(jsonData.error.message || 'MCP search failed');
}
// 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<SearchResult[]> {
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}`);

View File

@ -33,56 +33,21 @@ export default function McpBrowserPage() {
const [docLoading, setDocLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Function to call MCP tools
const callMcpTool = async (toolName: string, args: Record<string, string> = {}) => {
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<string, unknown>) => {
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() {
</div>
<p className="text-muted-foreground">
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{' '}
<span className="font-mono text-foreground">ask_stack_auth</span> tool; this page uses the internal docs API for listing and viewing pages).
Found <span className="font-medium text-foreground">{docs.length}</span> documentation pages.
</p>
</div>

View File

@ -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<CallToolResult> {
const componentMatch = content.match(/<EnhancedAPIPage\s+([^>]+)>/);
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<CallToolResult> {
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("<EnhancedAPIPage")) {
try {
return await extractOpenApiDetails(content, page);
} catch {
return {
content: [
{
type: "text",
text: `Title: ${page.data.title}\nDescription: ${page.data.description}\nContent:\n${content}`,
},
],
};
}
} else {
return {
content: [
{
type: "text",
text: `Title: ${page.data.title}\nDescription: ${page.data.description}\nContent:\n${content}`,
},
],
};
}
} catch {
const altPaths = [
`content/docs/${page.file.path}`,
`content/api/${page.file.path}`,
];
for (const altPath of altPaths) {
try {
const content = await readFile(altPath, "utf-8");
if (isApiPage && content.includes("<EnhancedAPIPage")) {
try {
return await extractOpenApiDetails(content, page);
} catch {
return {
content: [
{
type: "text",
text: `Title: ${page.data.title}\nDescription: ${page.data.description}\nContent:\n${content}`,
},
],
};
}
} else {
return {
content: [
{
type: "text",
text: `Title: ${page.data.title}\nDescription: ${page.data.description}\nContent:\n${content}`,
},
],
};
}
} catch {
continue;
}
}
return {
content: [
{
type: "text",
text: `Title: ${page.data.title}\nDescription: ${page.data.description}\nError: Could not read file at any of the attempted paths: ${filePath}, ${altPaths.join(", ")}`,
},
],
isError: true,
};
}
}
type SearchResult = {
title: string,
description: string,
url: string,
score: number,
snippet: string,
type: 'api' | 'docs',
apiEndpoint?: string | null,
};
async function searchDocsImpl(search_query: string, result_limit: number): Promise<CallToolResult> {
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<CallToolResult> {
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 });
}
}
}