diff --git a/app/mcp/route.ts b/app/mcp/route.ts new file mode 100644 index 0000000000000..ff07c62918066 --- /dev/null +++ b/app/mcp/route.ts @@ -0,0 +1,23 @@ +import {createMcpHandler} from 'mcp-handler'; + +import {registerTools} from '../../src/mcp'; + +// Trailing slash handling is done in middleware +const handler = createMcpHandler( + server => { + registerTools(server); + }, + { + serverInfo: { + name: 'sentry-docs-mcp', + version: '1.0.0', + }, + }, + { + basePath: '/', + maxDuration: 60, + disableSse: true, // Stateless HTTP only - no Redis required + } +); + +export {handler as GET, handler as POST}; diff --git a/next.config.ts b/next.config.ts index b89fa77ad5236..a021bd451815f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -130,6 +130,7 @@ if (process.env.NODE_ENV !== 'development' && !process.env.NEXT_PUBLIC_SENTRY_DS const nextConfig = { pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx', 'mdx'], trailingSlash: true, + skipTrailingSlashRedirect: true, serverExternalPackages: [ 'rehype-preset-minify', 'esbuild', diff --git a/package.json b/package.json index e9115480a6d72..f593f31a1b21d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "sidecar": "yarn spotlight-sidecar", "test": "vitest", "test:ci": "vitest run", + "eval": "vitest --config=src/mcp/evals/vitest.config.ts", + "eval:run": "vitest run --config=src/mcp/evals/vitest.config.ts", "enforce-redirects": "node ./scripts/no-vercel-json-redirects.mjs" }, "dependencies": { @@ -45,6 +47,7 @@ "@google-cloud/storage": "^7.7.0", "@mdx-js/loader": "^3.0.0", "@mdx-js/react": "^3.0.0", + "@modelcontextprotocol/sdk": "^1.25.3", "@pondorasti/remark-img-links": "^1.0.8", "@popperjs/core": "^2.11.8", "@prettier/plugin-xml": "^3.3.1", @@ -73,6 +76,7 @@ "js-cookie": "^3.0.5", "js-yaml": "^4.1.0", "match-sorter": "^6.3.4", + "mcp-handler": "^1.0.7", "mdx-bundler": "^10.0.1", "mermaid": "^11.11.0", "micromark": "^4.0.0", @@ -113,9 +117,11 @@ "tailwindcss-scoped-preflight": "^3.0.4", "textarea-markdown-editor": "^1.0.4", "unified": "^11.0.5", - "unist-util-remove": "^4.0.0" + "unist-util-remove": "^4.0.0", + "zod": "^4.3.6" }, "devDependencies": { + "@ai-sdk/anthropic": "^3.0.33", "@babel/preset-typescript": "^7.15.0", "@codecov/nextjs-webpack-plugin": "^1.9.0", "@spotlightjs/spotlight": "^2.5.0", @@ -126,6 +132,7 @@ "@types/react": "18.3.12", "@types/react-dom": "18.3.1", "@types/ws": "^8.5.10", + "ai": "^6.0.65", "autoprefixer": "^10.4.17", "concurrently": "^9.1.0", "dotenv-cli": "^7.4.1", @@ -142,6 +149,7 @@ "typescript": "^5", "vite-tsconfig-paths": "^5.0.1", "vitest": "^3.0.7", + "vitest-evals": "^0.5.0", "ws": "^8.17.1" }, "resolutions": { diff --git a/src/mcp/evals/setup.ts b/src/mcp/evals/setup.ts new file mode 100644 index 0000000000000..37e8c41cae2fa --- /dev/null +++ b/src/mcp/evals/setup.ts @@ -0,0 +1,13 @@ +import {config} from 'dotenv'; + +// Load environment variables from .env.local or .env +config({path: '.env.local'}); +config({path: '.env'}); + +// Verify ANTHROPIC_API_KEY is set +if (!process.env.ANTHROPIC_API_KEY) { + console.warn( + 'Warning: ANTHROPIC_API_KEY is not set. Evals will fail without it.\n' + + 'Set it in .env.local: ANTHROPIC_API_KEY=sk-ant-...' + ); +} diff --git a/src/mcp/evals/tests/get-doc-tree.eval.ts b/src/mcp/evals/tests/get-doc-tree.eval.ts new file mode 100644 index 0000000000000..2c774729fc0b5 --- /dev/null +++ b/src/mcp/evals/tests/get-doc-tree.eval.ts @@ -0,0 +1,67 @@ +import {describeEval} from 'vitest-evals'; + +import {NoOpTaskRunner, ToolPredictionScorer} from '../utils'; + +describeEval('get-doc-tree', { + data: () => + Promise.resolve([ + { + input: 'Show me the documentation structure', + expectedTools: [ + { + name: 'get_doc_tree', + arguments: {}, + }, + ], + }, + { + input: 'What sections are available in the JavaScript documentation?', + expectedTools: [ + { + name: 'get_doc_tree', + arguments: {path: 'platforms/javascript'}, + }, + ], + }, + { + input: 'Navigate the Python SDK docs, show me the table of contents', + expectedTools: [ + { + name: 'get_doc_tree', + arguments: {path: 'platforms/python'}, + }, + ], + }, + { + input: + 'I want to explore the configuration options, show me what subsections exist', + expectedTools: [ + { + name: 'get_doc_tree', + arguments: {path: 'configuration', depth: 2}, + }, + ], + }, + { + input: 'Give me a deep overview of all pages under product/sentry-basics', + expectedTools: [ + { + name: 'get_doc_tree', + arguments: {path: 'product/sentry-basics', depth: 3}, + }, + ], + }, + { + input: 'Show me just the top-level categories of documentation', + expectedTools: [ + { + name: 'get_doc_tree', + arguments: {depth: 1}, + }, + ], + }, + ]), + task: NoOpTaskRunner(), + scorers: [ToolPredictionScorer()], + threshold: 0.6, +}); diff --git a/src/mcp/evals/tests/get-doc.eval.ts b/src/mcp/evals/tests/get-doc.eval.ts new file mode 100644 index 0000000000000..91abec098c516 --- /dev/null +++ b/src/mcp/evals/tests/get-doc.eval.ts @@ -0,0 +1,58 @@ +import {describeEval} from 'vitest-evals'; + +import {NoOpTaskRunner, ToolPredictionScorer} from '../utils'; + +describeEval('get-doc', { + data: () => + Promise.resolve([ + { + input: 'Get the full documentation for /platforms/javascript/guides/nextjs', + expectedTools: [ + { + name: 'get_doc', + arguments: {path: '/platforms/javascript/guides/nextjs'}, + }, + ], + }, + { + input: 'Read the Python SDK setup guide at platforms/python', + expectedTools: [ + { + name: 'get_doc', + arguments: {path: 'platforms/python'}, + }, + ], + }, + { + input: 'Fetch the configuration page for the Go SDK', + expectedTools: [ + { + name: 'get_doc', + arguments: {path: '/platforms/go/configuration'}, + }, + ], + }, + { + input: 'Get the developer documentation for envelope formats', + expectedTools: [ + { + name: 'get_doc', + arguments: {path: 'envelope', site: 'develop'}, + }, + ], + }, + { + input: + 'I found a search result for /platforms/javascript/configuration/sampling, can you get the full content?', + expectedTools: [ + { + name: 'get_doc', + arguments: {path: '/platforms/javascript/configuration/sampling'}, + }, + ], + }, + ]), + task: NoOpTaskRunner(), + scorers: [ToolPredictionScorer()], + threshold: 0.6, +}); diff --git a/src/mcp/evals/tests/list-platforms.eval.ts b/src/mcp/evals/tests/list-platforms.eval.ts new file mode 100644 index 0000000000000..b13887b87cffd --- /dev/null +++ b/src/mcp/evals/tests/list-platforms.eval.ts @@ -0,0 +1,66 @@ +import {describeEval} from 'vitest-evals'; + +import {NoOpTaskRunner, ToolPredictionScorer} from '../utils'; + +describeEval('list-platforms', { + data: () => + Promise.resolve([ + { + input: 'What platforms does Sentry support?', + expectedTools: [ + { + name: 'list_platforms', + arguments: {}, + }, + ], + }, + { + input: 'List all available SDKs', + expectedTools: [ + { + name: 'list_platforms', + arguments: {}, + }, + ], + }, + { + input: 'What frameworks are available for JavaScript?', + expectedTools: [ + { + name: 'list_platforms', + arguments: {platform: 'javascript'}, + }, + ], + }, + { + input: 'Show me the guides available for the Python SDK', + expectedTools: [ + { + name: 'list_platforms', + arguments: {platform: 'python'}, + }, + ], + }, + { + input: 'Does Sentry support React Native?', + expectedTools: [ + { + name: 'list_platforms', + arguments: {platform: 'react-native'}, + }, + ], + }, + { + input: 'What mobile SDKs does Sentry have?', + expectedTools: [ + { + name: 'list_platforms', + arguments: {}, + }, + ], + }, + ]), + task: NoOpTaskRunner(), + scorers: [ToolPredictionScorer()], + threshold: 0.6, +}); diff --git a/src/mcp/evals/tests/search-docs.eval.ts b/src/mcp/evals/tests/search-docs.eval.ts new file mode 100644 index 0000000000000..227ef09872c18 --- /dev/null +++ b/src/mcp/evals/tests/search-docs.eval.ts @@ -0,0 +1,55 @@ +import {describeEval} from 'vitest-evals'; + +import {NoOpTaskRunner, ToolPredictionScorer} from '../utils'; + +describeEval('search-docs', { + data: () => + Promise.resolve([ + { + input: 'How do I set up Sentry in my Next.js app?', + expectedTools: [ + { + name: 'search_docs', + arguments: {query: 'setup Next.js', guide: 'javascript/nextjs'}, + }, + ], + }, + { + input: 'Show me the error handling configuration for Python', + expectedTools: [ + {name: 'search_docs', arguments: {query: 'error handling Python'}}, + ], + }, + { + input: 'How do I configure the tracesSampleRate option?', + expectedTools: [ + {name: 'search_docs', arguments: {query: 'tracesSampleRate configuration'}}, + ], + }, + { + input: 'Find documentation about beforeSend hook', + expectedTools: [{name: 'search_docs', arguments: {query: 'beforeSend hook'}}], + }, + { + input: 'How do I add breadcrumbs to my errors in React?', + expectedTools: [ + { + name: 'search_docs', + arguments: {query: 'breadcrumbs', guide: 'javascript/react'}, + }, + ], + }, + { + input: 'Search the developer docs for SDK architecture', + expectedTools: [ + { + name: 'search_docs', + arguments: {query: 'SDK architecture', site: 'develop'}, + }, + ], + }, + ]), + task: NoOpTaskRunner(), + scorers: [ToolPredictionScorer()], + threshold: 0.6, +}); diff --git a/src/mcp/evals/utils/index.ts b/src/mcp/evals/utils/index.ts new file mode 100644 index 0000000000000..af34693b5b620 --- /dev/null +++ b/src/mcp/evals/utils/index.ts @@ -0,0 +1,2 @@ +export {NoOpTaskRunner} from './runner'; +export {ToolPredictionScorer, type ExpectedToolCall} from './toolPredictionScorer'; diff --git a/src/mcp/evals/utils/runner.ts b/src/mcp/evals/utils/runner.ts new file mode 100644 index 0000000000000..7eb6b138e9c0a --- /dev/null +++ b/src/mcp/evals/utils/runner.ts @@ -0,0 +1,13 @@ +/** + * NoOpTaskRunner - A task runner that does nothing. + * + * In tool prediction evals, we don't actually execute the tools. + * We just verify that the AI correctly predicts which tools would be called. + * The scorer handles the actual prediction logic. + */ +export function NoOpTaskRunner() { + return function runner(input: string) { + // Simply return the input - the actual evaluation happens in the scorer + return Promise.resolve(input); + }; +} diff --git a/src/mcp/evals/utils/toolPredictionScorer.ts b/src/mcp/evals/utils/toolPredictionScorer.ts new file mode 100644 index 0000000000000..cad7101e0b44b --- /dev/null +++ b/src/mcp/evals/utils/toolPredictionScorer.ts @@ -0,0 +1,195 @@ +import {createAnthropic} from '@ai-sdk/anthropic'; +import {generateObject, type LanguageModel} from 'ai'; +import {z} from 'zod'; + +const anthropicProvider = createAnthropic(); + +/** + * Expected tool call structure for evaluation. + */ +export interface ExpectedToolCall { + /** Tool name (e.g., "search_docs", "get_doc") */ + name: string; + /** Expected arguments - can be partial, only checked fields are compared */ + arguments?: Record; +} + +/** + * Available tools in the sentry-docs MCP server. + * This description helps the scoring model understand what tools are available. + */ +const AVAILABLE_TOOLS = [ + { + name: 'search_docs', + description: + 'Search Sentry documentation for SDK setup, configuration, and usage guidance. Returns snippets with relevance scores.', + parameters: { + query: 'Search query in natural language (required)', + maxResults: 'Maximum results to return, 1-10 (optional, default 5)', + site: 'Which site to search: "docs" or "develop" (optional, default "docs")', + guide: + 'Filter by platform/guide, e.g., "javascript/nextjs", "python/django" (optional)', + }, + }, + { + name: 'get_doc', + description: + 'Fetch the full markdown content of a Sentry documentation page. Use after search_docs to get complete content.', + parameters: { + path: 'Documentation path, e.g., "/platforms/javascript/guides/nextjs" (required)', + site: 'Which site: "docs" or "develop" (optional, default "docs")', + }, + }, + { + name: 'list_platforms', + description: + 'List available SDK platforms and their guides in Sentry documentation. Without platform param returns all platforms, with platform param returns detailed info including guides.', + parameters: { + platform: + 'Specific platform slug to get details for, e.g., "javascript", "python" (optional)', + }, + }, + { + name: 'get_doc_tree', + description: + 'Get the documentation structure/table of contents for navigation. Helps understand documentation hierarchy.', + parameters: { + path: 'Starting path to get tree from, e.g., "platforms/javascript" (optional)', + depth: 'How many levels deep to include, 1-3 (optional, default 2)', + site: 'Which site: "docs" or "develop" (optional, default "docs")', + }, + }, +]; + +/** + * Schema for the prediction response from the scoring model. + */ +const predictionSchema = z.object({ + predictedTools: z.array( + z.object({ + name: z.string().describe('The name of the tool that would be called'), + argumentsJson: z + .string() + .optional() + .describe('JSON string of the arguments that would be passed to the tool'), + reasoning: z + .string() + .describe('Brief explanation of why this tool would be selected'), + }) + ), + score: z + .number() + .describe( + 'Score from 0.0 to 1.0 indicating how well the predicted tools match the expected' + ), + rationale: z + .string() + .describe( + 'Explanation of the score and any discrepancies between predicted and expected' + ), +}); + +/** + * Generate the prompt for the scoring model. + */ +function generatePrompt(userQuery: string, expectedTools: ExpectedToolCall[]): string { + const toolDescriptions = AVAILABLE_TOOLS.map( + tool => + `- **${tool.name}**: ${tool.description}\n Parameters: ${JSON.stringify(tool.parameters, null, 2)}` + ).join('\n\n'); + + const expectedDescription = expectedTools + .map( + (tool, i) => + `${i + 1}. Tool: ${tool.name}${tool.arguments ? `\n Arguments: ${JSON.stringify(tool.arguments)}` : ''}` + ) + .join('\n'); + + return `You are evaluating whether an AI assistant would correctly select MCP tools to answer a user query about Sentry documentation. + +## Available Tools + +${toolDescriptions} + +## User Query + +"${userQuery}" + +## Expected Tool Calls + +The expected tool calls for this query are: + +${expectedDescription} + +## Your Task + +1. Predict which tool(s) an AI assistant would call to answer the user's query. +2. Consider the most logical and efficient tool selection. +3. Compare your prediction against the expected tools. +4. Score how well the expected tools match what should be called: + - 1.0: Perfect match - the expected tools are exactly what should be called + - 0.8-0.9: Good match - minor differences in arguments or an extra optional tool + - 0.6-0.7: Acceptable - correct primary tool but missing secondary tools or wrong arguments + - 0.3-0.5: Partial match - some correct tools but significant issues + - 0.0-0.2: Poor match - wrong tools or completely off + +Focus on evaluating whether the EXPECTED tools make sense for the query, not whether you would choose differently.`; +} + +/** + * Default model for scoring - Claude Sonnet 4.5 for balance of quality and cost. + */ +const defaultModel = anthropicProvider('claude-sonnet-4-5-20250929'); + +/** + * Scorer options interface matching vitest-evals BaseScorerOptions + * with our custom expectedTools field. + */ +interface ToolPredictionScorerOptions { + input: string; + output: string; + expectedTools?: ExpectedToolCall[]; +} + +/** + * ToolPredictionScorer - Scores tool prediction accuracy using Claude. + * + * This scorer asks Claude to predict which tools would be called for a given + * user query, then compares against the expected tools to generate a score. + * + * @param model - Optional LanguageModel to use for scoring (defaults to Claude Sonnet) + * @returns Scorer function compatible with vitest-evals + */ +export function ToolPredictionScorer(model: LanguageModel = defaultModel) { + return async function scorer(opts: ToolPredictionScorerOptions) { + // If no expected tools provided, we can't score + if (!opts.expectedTools || opts.expectedTools.length === 0) { + return {score: null}; + } + + try { + const {object} = await generateObject({ + model, + prompt: generatePrompt(opts.input, opts.expectedTools), + schema: predictionSchema, + }); + + return { + score: object.score, + metadata: { + rationale: object.rationale, + predictedTools: object.predictedTools, + expectedTools: opts.expectedTools, + }, + }; + } catch (error) { + console.error('Error in ToolPredictionScorer:', error); + return { + score: 0, + metadata: { + error: error instanceof Error ? error.message : 'Unknown error', + }, + }; + } + }; +} diff --git a/src/mcp/evals/vitest.config.ts b/src/mcp/evals/vitest.config.ts new file mode 100644 index 0000000000000..a631a80540117 --- /dev/null +++ b/src/mcp/evals/vitest.config.ts @@ -0,0 +1,10 @@ +import {defineConfig} from 'vitest/config'; + +export default defineConfig({ + test: { + root: 'src/mcp/evals', + include: ['**/*.eval.ts'], + reporters: ['vitest-evals/reporter'], + setupFiles: ['./setup.ts'], + }, +}); diff --git a/src/mcp/index.ts b/src/mcp/index.ts new file mode 100644 index 0000000000000..1868c6bda1fc2 --- /dev/null +++ b/src/mcp/index.ts @@ -0,0 +1,13 @@ +import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; + +import {registerGetDoc} from './tools/get-doc'; +import {registerGetDocTree} from './tools/get-doc-tree'; +import {registerListPlatforms} from './tools/list-platforms'; +import {registerSearchDocs} from './tools/search-docs'; + +export function registerTools(server: McpServer) { + registerSearchDocs(server); + registerGetDoc(server); + registerListPlatforms(server); + registerGetDocTree(server); +} diff --git a/src/mcp/tools/get-doc-tree.ts b/src/mcp/tools/get-doc-tree.ts new file mode 100644 index 0000000000000..7f846a896f115 --- /dev/null +++ b/src/mcp/tools/get-doc-tree.ts @@ -0,0 +1,162 @@ +import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; +import {z} from 'zod'; + +import type {DocNode} from 'sentry-docs/docTree'; +import {getDocsRootNode, nodeForPath} from 'sentry-docs/docTree'; + +interface TreeNode { + path: string; + title: string; + children?: TreeNode[]; +} + +function filterTreeByDepth( + node: DocNode, + currentDepth: number, + maxDepth: number +): TreeNode { + const title = node.frontmatter.sidebar_title || node.frontmatter.title || node.slug; + + const result: TreeNode = { + path: node.path, + title, + }; + + if (currentDepth < maxDepth && node.children && node.children.length > 0) { + const visibleChildren = node.children.filter( + child => + !child.frontmatter.sidebar_hidden && + !child.frontmatter.draft && + (child.frontmatter.sidebar_title || child.frontmatter.title) + ); + + if (visibleChildren.length > 0) { + result.children = visibleChildren.map(child => + filterTreeByDepth(child, currentDepth + 1, maxDepth) + ); + } + } + + return result; +} + +function treeToMarkdown(node: TreeNode, indent: number = 0): string { + const prefix = ' '.repeat(indent); + let output = `${prefix}- **${node.title}** \`${node.path || '/'}\`\n`; + + if (node.children) { + for (const child of node.children) { + output += treeToMarkdown(child, indent + 1); + } + } + + return output; +} + +export function registerGetDocTree(server: McpServer) { + const description = [ + 'Get the documentation structure/table of contents for navigation.', + '', + 'Use this tool to:', + '- Understand the documentation hierarchy', + '- Find available sections and subsections', + '- Navigate to related documentation pages', + '', + '', + '- Without path: returns top-level structure', + '- With path: returns structure starting from that path', + '- Use depth to control how deep to traverse (1-3)', + '', + ].join('\n'); + + const paramsSchema = { + path: z + .string() + .optional() + .describe('Starting path to get tree from (e.g., "platforms/javascript")'), + depth: z + .number() + .int() + .min(1) + .max(3) + .default(2) + .describe('How many levels deep to include (1-3)'), + site: z + .enum(['docs', 'develop']) + .default('docs') + .describe('Which site: docs or develop'), + }; + + server.tool('get_doc_tree', description, paramsSchema, async ({path, depth, site}) => { + try { + // Note: For develop docs, we'd need getDevelopDocsRootNode but it's not exported + // For now, we only fully support docs site + if (site === 'develop') { + return { + content: [ + { + type: 'text' as const, + text: [ + '# Developer Docs Tree', + '', + 'The doc tree for develop.sentry.io is not available in this context.', + 'Use `search_docs(site="develop")` to search developer documentation.', + ].join('\n'), + }, + ], + }; + } + + const rootNode = await getDocsRootNode(); + + let startNode = rootNode; + if (path) { + const found = nodeForPath(rootNode, path); + if (!found) { + return { + content: [ + { + type: 'text' as const, + text: [ + `# Path Not Found: ${path}`, + '', + 'The specified path does not exist in the documentation tree.', + 'Use `get_doc_tree()` without a path to see the top-level structure.', + ].join('\n'), + }, + ], + }; + } + startNode = found; + } + + const tree = filterTreeByDepth(startNode, 0, depth); + + let output = `# Documentation Structure\n\n`; + if (path) { + output += `**Starting from**: \`${path}\`\n`; + } + output += `**Depth**: ${depth} level(s)\n\n`; + + output += treeToMarkdown(tree); + + output += '\n## Navigation Tips\n\n'; + output += '- Use `get_doc(path="...")` to read a specific page\n'; + output += '- Use `get_doc_tree(path="...")` to explore a subsection\n'; + output += '- Use `search_docs(query="...")` to find specific topics\n'; + + return { + content: [{type: 'text' as const, text: output}], + }; + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: `Error getting doc tree: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + ], + }; + } + }); +} diff --git a/src/mcp/tools/get-doc.ts b/src/mcp/tools/get-doc.ts new file mode 100644 index 0000000000000..990f1fcd74107 --- /dev/null +++ b/src/mcp/tools/get-doc.ts @@ -0,0 +1,139 @@ +import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; +import {z} from 'zod'; + +const MAX_CONTENT_LENGTH = 10000; // 10KB limit to avoid token bloat + +export function registerGetDoc(server: McpServer) { + const description = [ + 'Fetch the full markdown content of a Sentry documentation page.', + '', + 'Use this tool when you need to:', + '- Read complete documentation for a topic', + '- Get detailed code examples and configuration', + '- Access full context after finding pages via search_docs', + '', + '', + '- Use paths from search_docs results', + '- Paths like "/platforms/javascript/guides/nextjs"', + '- Content over 10KB will be truncated', + '', + ].join('\n'); + + const paramsSchema = { + path: z + .string() + .describe( + 'Documentation path (e.g., "/platforms/javascript/guides/nextjs"). Get from search_docs.' + ), + site: z + .enum(['docs', 'develop']) + .default('docs') + .describe('Which site: docs (docs.sentry.io) or develop (develop.sentry.io)'), + }; + + server.tool('get_doc', description, paramsSchema, async ({path, site}) => { + // Normalize path - remove leading slash if present, remove trailing slash + let normalizedPath = path.replace(/^\/+/, '').replace(/\/+$/, ''); + + // Don't add .md if path already has it + if (!normalizedPath.endsWith('.md')) { + normalizedPath = `${normalizedPath}.md`; + } + + const baseUrl = + site === 'develop' ? 'https://develop.sentry.io' : 'https://docs.sentry.io'; + + const docUrl = `${baseUrl}/${normalizedPath}`; + + try { + const response = await fetch(docUrl, { + headers: { + Accept: 'text/plain, text/markdown', + 'User-Agent': 'sentry-docs-mcp/1.0', + }, + }); + + if (!response.ok) { + if (response.status === 404) { + return { + content: [ + { + type: 'text' as const, + text: [ + `# Document Not Found`, + '', + `**Path**: ${path}`, + `**URL**: ${docUrl}`, + '', + 'The document was not found. Suggestions:', + '- Verify the path is correct (check search_docs results)', + '- Try without file extension in the path', + '- The page may have been moved or renamed', + '', + 'Use `search_docs` to find the correct path.', + ].join('\n'), + }, + ], + }; + } + throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`); + } + + let content = await response.text(); + + // Check if we got HTML instead of markdown + if (content.trim().startsWith(' MAX_CONTENT_LENGTH; + if (wasTruncated) { + content = content.slice(0, MAX_CONTENT_LENGTH); + // Try to truncate at a natural break point + const lastNewline = content.lastIndexOf('\n\n'); + if (lastNewline > MAX_CONTENT_LENGTH * 0.8) { + content = content.slice(0, lastNewline); + } + } + + let output = `# Documentation: ${path}\n\n`; + output += `**Source**: ${docUrl}\n`; + if (wasTruncated) { + output += `**Note**: Content truncated (over 10KB). View full doc at the URL above.\n`; + } + output += '\n---\n\n'; + output += content; + output += '\n\n---\n'; + + return { + content: [{type: 'text' as const, text: output}], + }; + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: `Error fetching document: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + ], + }; + } + }); +} diff --git a/src/mcp/tools/list-platforms.ts b/src/mcp/tools/list-platforms.ts new file mode 100644 index 0000000000000..2a80c75b92388 --- /dev/null +++ b/src/mcp/tools/list-platforms.ts @@ -0,0 +1,111 @@ +import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; +import {z} from 'zod'; + +import {extractPlatforms, getDocsRootNode} from 'sentry-docs/docTree'; + +export function registerListPlatforms(server: McpServer) { + const description = [ + 'List available SDK platforms and their guides in Sentry documentation.', + '', + 'Use this tool to:', + '- Discover all supported platforms (JavaScript, Python, Go, etc.)', + '- Find available framework guides for a platform', + '- Get platform slugs for filtering search_docs queries', + '', + '', + '- Without platform param: returns list of all platforms', + '- With platform param: returns detailed info including guides', + '', + ].join('\n'); + + const paramsSchema = { + platform: z + .string() + .optional() + .describe( + 'Specific platform slug to get details for (e.g., "javascript", "python")' + ), + }; + + server.tool('list_platforms', description, paramsSchema, async ({platform}) => { + try { + const rootNode = await getDocsRootNode(); + const platforms = extractPlatforms(rootNode); + + if (platform) { + // Return details for specific platform + const found = platforms.find(p => p.key === platform || p.name === platform); + + if (!found) { + const availablePlatforms = platforms.map(p => p.key).join(', '); + return { + content: [ + { + type: 'text' as const, + text: [ + `# Platform Not Found: ${platform}`, + '', + `Available platforms: ${availablePlatforms}`, + ].join('\n'), + }, + ], + }; + } + + let output = `# Platform: ${found.title}\n\n`; + output += `**Slug**: ${found.key}\n`; + output += `**URL**: ${found.url}\n`; + if (found.sdk) { + output += `**SDK**: ${found.sdk}\n`; + } + if (found.categories?.length) { + output += `**Categories**: ${found.categories.join(', ')}\n`; + } + output += '\n'; + + if (found.guides && found.guides.length > 0) { + output += `## Guides (${found.guides.length})\n\n`; + for (const guide of found.guides) { + output += `- **${guide.title}** - \`${guide.key}\`\n`; + output += ` URL: ${guide.url}\n`; + } + } else { + output += '*No framework-specific guides available for this platform.*\n'; + } + + return { + content: [{type: 'text' as const, text: output}], + }; + } + + // Return list of all platforms + let output = `# Available Platforms\n\n`; + output += `Found ${platforms.length} platforms. Use \`list_platforms(platform='...')\` for details.\n\n`; + + for (const p of platforms) { + const guidesCount = p.guides?.length || 0; + const guidesInfo = guidesCount > 0 ? ` (${guidesCount} guides)` : ''; + output += `- **${p.title}** - \`${p.key}\`${guidesInfo}\n`; + } + + output += '\n## Usage\n\n'; + output += 'Use platform slugs with `search_docs(guide="platform/framework")`\n'; + output += 'Examples:\n'; + output += '- `search_docs(query="setup", guide="javascript/nextjs")`\n'; + output += '- `search_docs(query="configuration", guide="python/django")`\n'; + + return { + content: [{type: 'text' as const, text: output}], + }; + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: `Error listing platforms: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + ], + }; + } + }); +} diff --git a/src/mcp/tools/search-docs.ts b/src/mcp/tools/search-docs.ts new file mode 100644 index 0000000000000..41effa242b31f --- /dev/null +++ b/src/mcp/tools/search-docs.ts @@ -0,0 +1,133 @@ +import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; +import {SentryGlobalSearch, standardSDKSlug} from '@sentry-internal/global-search'; +import {z} from 'zod'; + +const docsSearch = new SentryGlobalSearch([ + {site: 'docs', pathBias: true, platformBias: true}, +]); +const developSearch = new SentryGlobalSearch(['develop']); + +/** + * Strip HTML tags from a string iteratively to handle nested angle brackets. + * Prevents incomplete sanitization like `<