add search, fetch tools to MCP server for better ChatGPT compatibility (#917)

This commit is contained in:
Jakob Steiner 2025-11-17 16:27:21 +01:00 committed by GitHub
parent 6af55895e8
commit bc57d0d248
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 504 additions and 157 deletions

View File

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

View File

@ -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"],
},
},
},
},
},

View File

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

View File

@ -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 &quot;{query}&quot; {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;
};