mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
Mirror main branch to main-mirror-for-wdb / lint_and_build (push) Has been cancelled
Publish npm packages / publish (push) Has been cancelled
Publish Swift SDK to prerelease repo / publish (push) Has been cancelled
Sync Main to Dev / sync-commits (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Automated AI QA review pipeline and human-verified knowledge base consulted first * Internal MCP review tool: call log viewer, conversation replay, add/edit/publish Q&A, knowledge editor, and analytics * Docs search now preserves follow-up conversation context * **Documentation** * Added “Ask DeepWiki” badge to README * **Chores** * Added local SpacetimeDB background service and internal-tool app scaffolding <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: mantrakp04 <mantrakp@gmail.com> Co-authored-by: Mantra <87142457+mantrakp04@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
493 lines
14 KiB
TypeScript
493 lines
14 KiB
TypeScript
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,
|
|
},
|
|
],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
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 });
|
|
}
|
|
}
|
|
}
|