mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
add search, fetch tools to MCP server for better ChatGPT compatibility (#917)
This commit is contained in:
parent
6af55895e8
commit
bc57d0d248
@ -12,7 +12,6 @@ const google = createGoogleGenerativeAI({
|
||||
|
||||
// Helper function to get error message
|
||||
function getErrorMessage(error: unknown): string {
|
||||
console.log('Error in chat API:', error);
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
@ -25,10 +24,13 @@ export async function POST(request: Request) {
|
||||
// Create MCP client for Stack Auth documentation with error handling
|
||||
let tools = {};
|
||||
try {
|
||||
// Use local MCP server in development, production server in production
|
||||
const mcpUrl = process.env.NODE_ENV === 'development'
|
||||
? new URL('/api/internal/mcp', 'https://localhost:8104')
|
||||
: new URL('/api/internal/mcp', 'https://mcp.stack-auth.com');
|
||||
|
||||
const stackAuthMcp = await createMCPClient({
|
||||
transport: new StreamableHTTPClientTransport(
|
||||
new URL('/api/internal/mcp', 'https://mcp.stack-auth.com/api/internal/mcp')
|
||||
),
|
||||
transport: new StreamableHTTPClientTransport(mcpUrl),
|
||||
});
|
||||
tools = await stackAuthMcp.tools();
|
||||
} catch (error) {
|
||||
@ -55,6 +57,12 @@ You are Stack Auth's AI assistant. You help users with Stack Auth - a complete a
|
||||
|
||||
Think step by step about what to say. Being wrong is 100x worse than saying you don't know.
|
||||
|
||||
## TOOL USAGE WORKFLOW:
|
||||
1. **FIRST**, use \`search_docs\` with relevant keywords to find related documentation
|
||||
2. **THEN**, use \`get_docs_by_id\` to retrieve the full content of the most relevant pages
|
||||
3. Base your answer on the actual documentation content retrieved
|
||||
4. When referring to API endpoints, **always cite the actual endpoint** (e.g., "GET /users/me") not the documentation URL
|
||||
|
||||
## CORE RESPONSIBILITIES:
|
||||
1. Help users implement Stack Auth in their applications
|
||||
2. Answer questions about authentication, user management, and authorization using Stack Auth
|
||||
@ -85,13 +93,23 @@ When users need personalized support, have complex issues, or ask for help beyon
|
||||
|
||||
## RESPONSE FORMAT:
|
||||
- Use markdown formatting for better readability
|
||||
- Include code blocks with proper syntax highlighting
|
||||
- **ALWAYS include code examples** - Show users how to actually implement solutions
|
||||
- Include code blocks with proper syntax highlighting (typescript, bash, etc.)
|
||||
- Use bullet points for lists
|
||||
- Bold important concepts
|
||||
- Provide practical examples when possible
|
||||
- Provide practical, working examples
|
||||
- Focus on giving complete, helpful answers
|
||||
- **When referencing documentation, use links with the base URL: https://docs.stack-auth.com**
|
||||
- Example: For setup docs, use https://docs.stack-auth.com/docs/next/getting-started/setup
|
||||
- Example: For setup docs, use https://docs.stack-auth.com/docs/getting-started/setup
|
||||
|
||||
## CODE EXAMPLE GUIDELINES:
|
||||
- For API calls, show both the HTTP endpoint AND the SDK method
|
||||
- For example, when explaining "get current user":
|
||||
* Show the HTTP API endpoint: GET /users/me
|
||||
* Show the SDK usage: const user = useUser();
|
||||
* Include necessary imports and authentication headers
|
||||
- Always show complete, runnable code snippets with proper language tags
|
||||
- Include context like "HTTP API", "SDK (React)", "SDK (Next.js)" etc.
|
||||
|
||||
## WHEN UNSURE:
|
||||
- If you're unsure about a Stack Auth feature, say "As an AI, I don't know" or "As an AI, I'm not certain" clearly
|
||||
@ -124,7 +142,7 @@ Remember: You're here to help users succeed with Stack Auth. Be helpful but conc
|
||||
maxSteps: 50,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
temperature: 0.1,
|
||||
temperature: 0.3, // Slightly higher for more natural, detailed responses
|
||||
});
|
||||
|
||||
return result.toDataStreamResponse({
|
||||
|
||||
@ -3,6 +3,7 @@ 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";
|
||||
|
||||
const nodeClient = process.env.NEXT_PUBLIC_POSTHOG_KEY
|
||||
@ -10,8 +11,10 @@ const nodeClient = 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 } }) {
|
||||
|
||||
async function extractOpenApiDetails(
|
||||
content: string,
|
||||
page: { data: { title: string, description?: string } },
|
||||
) {
|
||||
const componentMatch = content.match(/<EnhancedAPIPage\s+([^>]+)>/);
|
||||
if (componentMatch) {
|
||||
const props = componentMatch[1];
|
||||
@ -104,6 +107,23 @@ const filteredApiPages = apiPages.filter((page) => {
|
||||
|
||||
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");
|
||||
@ -117,6 +137,104 @@ ID: ${page.url}
|
||||
)
|
||||
.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(
|
||||
@ -132,7 +250,7 @@ const handler = createMcpHandler(
|
||||
return {
|
||||
content: [{ type: "text", text: pageSummaries }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
server.tool(
|
||||
"search_docs",
|
||||
@ -148,8 +266,19 @@ const handler = createMcpHandler(
|
||||
distinctId: "mcp-handler",
|
||||
});
|
||||
|
||||
const results = [];
|
||||
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) {
|
||||
@ -161,32 +290,63 @@ const handler = createMcpHandler(
|
||||
let score = 0;
|
||||
const title = page.data.title || '';
|
||||
const description = page.data.description || '';
|
||||
const titleLower = title.toLowerCase();
|
||||
const descriptionLower = description.toLowerCase();
|
||||
|
||||
// Title matching (highest priority)
|
||||
if (title.toLowerCase().includes(queryLower)) {
|
||||
if (title.toLowerCase() === queryLower) {
|
||||
// Exact phrase match in title (highest priority)
|
||||
if (titleLower.includes(queryLower)) {
|
||||
if (titleLower === queryLower) {
|
||||
score += 100; // Exact match
|
||||
} else if (title.toLowerCase().startsWith(queryLower)) {
|
||||
} else if (titleLower.startsWith(queryLower)) {
|
||||
score += 80; // Starts with
|
||||
} else {
|
||||
score += 60; // Contains
|
||||
}
|
||||
}
|
||||
|
||||
// Description matching
|
||||
if (description.toLowerCase().includes(queryLower)) {
|
||||
// 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' && tocItem.title.toLowerCase().includes(queryLower)) {
|
||||
score += 30;
|
||||
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 {
|
||||
const filePath = `content/${page.file.path}`;
|
||||
// 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
|
||||
@ -200,44 +360,70 @@ const handler = createMcpHandler(
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (textContent.toLowerCase().includes(queryLower)) {
|
||||
score += 20;
|
||||
const textContentLower = textContent.toLowerCase();
|
||||
|
||||
// Find snippet around the match
|
||||
const matchIndex = textContent.toLowerCase().indexOf(queryLower);
|
||||
// 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'
|
||||
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'
|
||||
type: page.url.startsWith('/api/') ? 'api' : 'docs',
|
||||
apiEndpoint
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
} 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'
|
||||
type: page.url.startsWith('/api/') ? 'api' : 'docs',
|
||||
apiEndpoint
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -249,9 +435,17 @@ const handler = createMcpHandler(
|
||||
.slice(0, result_limit);
|
||||
|
||||
const searchResultText = sortedResults.length > 0
|
||||
? sortedResults.map(result =>
|
||||
`Title: ${result.title}\nDescription: ${result.description}\nURL: ${result.url}\nType: ${result.type}\nScore: ${result.score}\nSnippet: ${result.snippet}\n`
|
||||
).join('\n---\n')
|
||||
? 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 {
|
||||
@ -263,109 +457,13 @@ const handler = createMcpHandler(
|
||||
"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() },
|
||||
async ({ id }) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
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 ({}) => {
|
||||
async () => {
|
||||
nodeClient?.capture({
|
||||
event: "get_stack_auth_setup_instructions",
|
||||
properties: {},
|
||||
@ -395,7 +493,52 @@ const handler = createMcpHandler(
|
||||
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 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 }),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// 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,
|
||||
);
|
||||
},
|
||||
{
|
||||
@ -428,6 +571,34 @@ const handler = createMcpHandler(
|
||||
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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -114,7 +114,10 @@ async function callMcpServer(search_query: string): Promise<SearchResult[]> {
|
||||
title = line.substring(7);
|
||||
} else if (line.startsWith('Description: ')) {
|
||||
description = line.substring(13);
|
||||
} else if (line.startsWith('Documentation URL: ')) {
|
||||
url = line.substring(19);
|
||||
} else if (line.startsWith('URL: ')) {
|
||||
// Fallback for old format
|
||||
url = line.substring(5);
|
||||
} else if (line.startsWith('Type: ')) {
|
||||
type = line.substring(6);
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises';
|
||||
import { ExternalLink, FileText, Maximize2, Minimize2, Send, X } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, ExternalLink, FileText, Maximize2, Minimize2, Send, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSidebar } from '../layouts/sidebar-context';
|
||||
import { MessageFormatter } from './message-formatter';
|
||||
@ -23,50 +23,205 @@ function StackIcon({ size = 20, className }: { size?: number, className?: string
|
||||
);
|
||||
}
|
||||
|
||||
// Separate component for search results to properly use React hooks
|
||||
const SearchDocsDisplay = ({ toolCall }: { toolCall: { toolName: string, args?: { search_query?: string }, result?: { content?: { text: string }[], text?: string } } | string }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const query = (toolCall as { args?: { search_query?: string } }).args?.search_query || "...";
|
||||
// Try multiple ways to get the result text
|
||||
const resultText = ((toolCall as { result?: { content?: { text: string }[], text?: string } }).result?.content?.[0]?.text
|
||||
|| (toolCall as { result?: { content?: { text: string }[], text?: string } }).result?.text
|
||||
|| (typeof (toolCall as { result?: string }).result === 'string' ? (toolCall as { result?: string }).result : undefined)) ?? "";
|
||||
|
||||
// Count how many results were found
|
||||
const resultCount = (resultText.match(/Title:/g) || []).length;
|
||||
|
||||
// Parse search results
|
||||
const parseSearchResults = () => {
|
||||
const results = [];
|
||||
const sections = resultText.split('---').map((s: string) => s.trim()).filter(Boolean);
|
||||
|
||||
for (const section of sections) {
|
||||
const titleMatch = section.match(/Title:\s*([^\n]+)/);
|
||||
const descMatch = section.match(/Description:\s*([^\n]+)/);
|
||||
const urlMatch = section.match(/Documentation URL:\s*([^\n]+)/);
|
||||
const endpointMatch = section.match(/API Endpoint:\s*([^\n]+)/);
|
||||
const scoreMatch = section.match(/Score:\s*(\d+)/);
|
||||
|
||||
if (titleMatch) {
|
||||
results.push({
|
||||
title: titleMatch[1].trim(),
|
||||
description: descMatch?.[1]?.trim(),
|
||||
url: urlMatch?.[1]?.trim(),
|
||||
endpoint: endpointMatch?.[1]?.trim(),
|
||||
score: scoreMatch?.[1] ? parseInt(scoreMatch[1]) : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results.slice(0, 10); // Limit to top 10 for display
|
||||
};
|
||||
|
||||
const results = parseSearchResults();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col bg-purple-50 dark:bg-purple-950/30 border border-purple-200 dark:border-purple-800 rounded-lg text-xs mb-2">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 p-2 w-full text-left hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-colors rounded-lg"
|
||||
>
|
||||
<FileText className="w-3 h-3 text-purple-600 dark:text-purple-400 flex-shrink-0" />
|
||||
<span className="text-purple-700 dark:text-purple-300 font-medium flex-1">
|
||||
Searched for "{query}" • {resultCount} {resultCount === 1 ? 'result' : 'results'}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-3 h-3 text-purple-600 dark:text-purple-400 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-purple-600 dark:text-purple-400 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && results.length > 0 && (
|
||||
<div className="px-2 pb-2 space-y-1.5 max-h-64 overflow-y-auto">
|
||||
{results.map((result, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-2 bg-white dark:bg-gray-900/50 rounded border border-purple-200/50 dark:border-purple-800/50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-purple-900 dark:text-purple-100 truncate">
|
||||
{result.title}
|
||||
</div>
|
||||
{result.endpoint && (
|
||||
<div className="text-purple-600 dark:text-purple-400 font-mono text-[10px] mt-0.5">
|
||||
{result.endpoint}
|
||||
</div>
|
||||
)}
|
||||
{result.description && (
|
||||
<div className="text-purple-700 dark:text-purple-300 text-[10px] mt-1 line-clamp-2">
|
||||
{result.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{result.url && (
|
||||
<a
|
||||
href={`https://docs.stack-auth.com${result.url}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Component to render tool calls
|
||||
const ToolCallDisplay = ({
|
||||
toolCall,
|
||||
}: {
|
||||
toolCall: {
|
||||
toolName: string,
|
||||
args?: { id?: string },
|
||||
result?: { content: { text: string }[] },
|
||||
},
|
||||
toolCall: { toolName: string, args?: { id?: string, search_query?: string }, result?: { content?: { text: string }[], text?: string } },
|
||||
}) => {
|
||||
if (toolCall.toolName === "get_docs_by_id") {
|
||||
// Handle search_docs tool
|
||||
if (toolCall.toolName === "search_docs") {
|
||||
return <SearchDocsDisplay toolCall={toolCall} />;
|
||||
}
|
||||
|
||||
// Handle get_docs_by_id and fetch tools (they work the same)
|
||||
if (toolCall.toolName === "get_docs_by_id" || toolCall.toolName === "fetch") {
|
||||
const docId = toolCall.args?.id;
|
||||
let docTitle = "Loading...";
|
||||
let apiEndpoint: string | null = null;
|
||||
|
||||
const titleMatch = toolCall.result?.content[0]?.text.match(/Title:\s*(.*)/);
|
||||
// Try multiple ways to get the result text
|
||||
const resultText = (toolCall.result?.content?.[0]?.text
|
||||
|| toolCall.result?.text
|
||||
|| (typeof toolCall.result === 'string' ? toolCall.result : undefined)) ?? "";
|
||||
|
||||
// Extract title - more robust matching
|
||||
const titleMatch = resultText.match(/Title:\s*([^\n]+)/);
|
||||
if (titleMatch?.[1]) {
|
||||
docTitle = titleMatch[1].trim();
|
||||
} else {
|
||||
docTitle = 'No Title Found';
|
||||
// Fallback: try to extract from the docId if available
|
||||
if (docId) {
|
||||
const pathParts = String(docId).split('/').filter(Boolean);
|
||||
docTitle = pathParts[pathParts.length - 1]?.replace(/-/g, ' ') || 'Documentation';
|
||||
} else {
|
||||
docTitle = 'Documentation';
|
||||
}
|
||||
}
|
||||
|
||||
// Extract API endpoint if present
|
||||
const endpointMatch = resultText.match(/API Endpoint:\s*([^\n]+)/);
|
||||
if (endpointMatch?.[1]) {
|
||||
apiEndpoint = endpointMatch[1].trim();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-2 bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-lg text-xs mb-2">
|
||||
<FileText className="w-3 h-3 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-blue-700 dark:text-blue-300 font-medium">
|
||||
{docTitle}
|
||||
</span>
|
||||
{docId && (
|
||||
<a
|
||||
href={`https://docs.stack-auth.com${encodeURI(
|
||||
(String(docId).startsWith('/') ? String(docId) : `/${String(docId)}`)
|
||||
)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
<span>Open</span>
|
||||
</a>
|
||||
<div className="flex flex-col gap-1 p-2 bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-lg text-xs mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-3 h-3 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-blue-700 dark:text-blue-300 font-medium">
|
||||
{docTitle}
|
||||
</span>
|
||||
{docId && (
|
||||
<a
|
||||
href={`https://docs.stack-auth.com${encodeURI(
|
||||
(String(docId).startsWith('/') ? String(docId) : `/${String(docId)}`)
|
||||
)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 transition-colors ml-auto"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
<span>Open</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{apiEndpoint && (
|
||||
<div className="pl-5 text-blue-600 dark:text-blue-400 font-mono">
|
||||
{apiEndpoint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle search tool (for ChatGPT compatibility)
|
||||
if (toolCall.toolName === "search") {
|
||||
// Try multiple ways to get the result text
|
||||
const resultText = (toolCall.result?.content?.[0]?.text
|
||||
|| toolCall.result?.text
|
||||
|| (typeof toolCall.result === 'string' ? toolCall.result : undefined)) ?? "";
|
||||
let resultCount = 0;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(resultText);
|
||||
resultCount = parsed.results?.length || 0;
|
||||
} catch {
|
||||
// If not JSON, try counting
|
||||
resultCount = (resultText.match(/title:/gi) || []).length;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-2 bg-purple-50 dark:bg-purple-950/30 border border-purple-200 dark:border-purple-800 rounded-lg text-xs mb-2">
|
||||
<FileText className="w-3 h-3 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-purple-700 dark:text-purple-300 font-medium">
|
||||
Searched • {resultCount} {resultCount === 1 ? 'result' : 'results'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user