Skip to main content
Glama
tcsenpai

Universal Documentation MCP Server

by tcsenpai
httpServer.ts53.1 kB
#!/usr/bin/env node import express from 'express'; import cors from 'cors'; import { randomUUID } from 'node:crypto'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, McpError, isInitializeRequest, } from '@modelcontextprotocol/sdk/types.js'; import { GitBookScraper } from './scraper.js'; import { SQLiteStore } from './sqliteStore.js'; import { gitBookConfig, validateConfig, logConfig, getCacheFilePath } from './config.js'; import { DomainDetector, DomainInfo } from './domainDetector.js'; import { ResponseUtils } from './responseUtils.js'; class GitBookMCPHttpServer { private server: Server; private scraper: GitBookScraper; private store: SQLiteStore; private domainInfo: DomainInfo; private transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; constructor() { // Validate configuration validateConfig(); this.domainInfo = { name: gitBookConfig.serverName, description: gitBookConfig.serverDescription, keywords: gitBookConfig.domainKeywords, toolPrefix: gitBookConfig.toolPrefix }; this.server = new Server( { name: this.domainInfo.name, version: gitBookConfig.serverVersion, }, { capabilities: { tools: { listChanged: true }, prompts: { listChanged: true } } } ); this.scraper = new GitBookScraper(gitBookConfig.gitbookUrl); this.store = new SQLiteStore(gitBookConfig.gitbookUrl); this.setupHandlers(); } private setupHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: `${this.domainInfo.toolPrefix}search_content`, description: `Search across all ${this.domainInfo.description} for ${this.domainInfo.keywords.slice(0, 5).join(', ')}`, inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query', }, continuation_token: { type: 'string', description: 'Continuation token for paginated results', }, limit: { type: 'number', description: 'Maximum number of results to return (default: 20, max: 50)', minimum: 1, maximum: 50, }, offset: { type: 'number', description: 'Number of results to skip for pagination (default: 0)', minimum: 0, }, }, required: [], }, }, { name: `${this.domainInfo.toolPrefix}get_page_section`, description: `Get a specific section from a page in ${this.domainInfo.description}`, inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Page path (e.g., "/sdk/websdk")', }, section: { type: 'string', description: 'Section identifier or heading text', }, }, required: ['path'], }, }, { name: `${this.domainInfo.toolPrefix}get_page_outline`, description: `Get the structure and outline of a specific page in ${this.domainInfo.description}`, inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Page path (e.g., "/sdk/websdk")', }, }, required: ['path'], }, }, { name: `${this.domainInfo.toolPrefix}get_page`, description: `Get a specific page from ${this.domainInfo.name} by path`, inputSchema: { type: "object", properties: { path: { type: "string", description: "Page path (e.g., '/api/authentication' or '/sdk/quickstart')" } }, required: ["path"] } }, { name: `${this.domainInfo.toolPrefix}search_code`, description: `Search for code blocks and programming examples in ${this.domainInfo.description}`, inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query for code (language, function, keyword, etc.)', }, language: { type: 'string', description: 'Filter by programming language (e.g., "javascript", "python")', }, path: { type: 'string', description: 'Optional: search within specific page path', }, limit: { type: 'number', description: 'Maximum number of code blocks to return (default: 10, max: 30)', minimum: 1, maximum: 30, }, }, required: ['query'], }, }, { name: `${this.domainInfo.toolPrefix}get_related_pages`, description: `Find pages related to a specific page or topic in ${this.domainInfo.description}`, inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Page path to find related pages for', }, topic: { type: 'string', description: 'Topic or keyword to find related pages for', }, limit: { type: 'number', description: 'Maximum number of related pages to return (default: 5, max: 15)', minimum: 1, maximum: 15, }, }, required: [], }, }, { name: `${this.domainInfo.toolPrefix}list_sections`, description: `Get the table of contents for ${this.domainInfo.description}`, inputSchema: { type: "object", properties: {}, required: [] } }, { name: `${this.domainInfo.toolPrefix}get_section_pages`, description: `Get all pages in a specific section of ${this.domainInfo.name}`, inputSchema: { type: "object", properties: { section: { type: "string", description: "Section name (e.g., 'API Reference' or 'Getting Started')" } }, required: ["section"] } }, { name: `${this.domainInfo.toolPrefix}refresh_content`, description: `Force refresh the cached content from ${this.domainInfo.name}`, inputSchema: { type: "object", properties: {}, required: [] } }, { name: `${this.domainInfo.toolPrefix}get_code_blocks`, description: `Extract all code blocks from a specific page with syntax highlighting`, inputSchema: { type: "object", properties: { path: { type: "string", description: "Page path (e.g., '/api/authentication')" } }, required: ["path"] } }, { name: `${this.domainInfo.toolPrefix}get_markdown`, description: `Get a page's content formatted as clean markdown`, inputSchema: { type: "object", properties: { path: { type: "string", description: "Page path (e.g., '/api/authentication')" } }, required: ["path"] } } ] }; }); // Add the same tool handlers as the stdio version this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case `${this.domainInfo.toolPrefix}search_content`: return await this.handleSearchContent(args); case `${this.domainInfo.toolPrefix}get_page_section`: return await this.handleGetPageSection(args); case `${this.domainInfo.toolPrefix}get_page_outline`: return await this.handleGetPageOutline(args); case `${this.domainInfo.toolPrefix}get_page`: return await this.handleGetPage(args); case `${this.domainInfo.toolPrefix}search_code`: return await this.handleSearchCode(args); case `${this.domainInfo.toolPrefix}get_related_pages`: return await this.handleGetRelatedPages(args); case `${this.domainInfo.toolPrefix}list_sections`: return await this.handleListSections(); case `${this.domainInfo.toolPrefix}get_section_pages`: return await this.handleGetSectionPages(args); case `${this.domainInfo.toolPrefix}refresh_content`: return await this.handleRefreshContent(); case `${this.domainInfo.toolPrefix}get_code_blocks`: return await this.handleGetCodeBlocks(args); case `${this.domainInfo.toolPrefix}get_markdown`: return await this.handleGetMarkdown(args); default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${name}` ); } } catch (error) { if (error instanceof McpError) { throw error; } throw new McpError( ErrorCode.InternalError, `Error executing tool ${name}: ${error instanceof Error ? error.message : String(error)}` ); } }); // Add prompt handlers (same as stdio version) this.server.setRequestHandler(ListPromptsRequestSchema, async () => { return { prompts: [ { name: "explain_section", description: `Generate a comprehensive explanation or tutorial for a specific section of ${this.domainInfo.name}`, arguments: [ { name: "section", description: "The section name to explain", required: true } ] }, { name: "summarize_page", description: `Create a concise summary of a specific page from ${this.domainInfo.name}`, arguments: [ { name: "path", description: "The page path to summarize", required: true } ] }, { name: "compare_sections", description: `Compare and contrast different sections of ${this.domainInfo.name}`, arguments: [ { name: "section1", description: "First section to compare", required: true }, { name: "section2", description: "Second section to compare", required: true } ] }, { name: "api_reference", description: `Format content from ${this.domainInfo.name} as a structured API reference`, arguments: [ { name: "path", description: "The page path containing API information", required: true } ] }, { name: "quick_start_guide", description: `Generate a quick start guide based on ${this.domainInfo.name} content`, arguments: [ { name: "topic", description: "The topic or feature to create a quick start guide for", required: true } ] } ] }; }); this.server.setRequestHandler(GetPromptRequestSchema, async (request) => { const { name, arguments: args } = request.params; switch (name) { case "explain_section": return await this.handleExplainSection(args); case "summarize_page": return await this.handleSummarizePage(args); case "compare_sections": return await this.handleCompareSections(args); case "api_reference": return await this.handleApiReference(args); case "quick_start_guide": return await this.handleQuickStartGuide(args); default: throw new McpError( ErrorCode.MethodNotFound, `Unknown prompt: ${name}` ); } }); } // Tool handler methods - using dynamic token-aware pagination private async handleSearchContent(args: any) { let query: string; let requestedLimit = Math.min(args.limit || 20, 50); let offset = args.offset || 0; // Handle continuation token if (args.continuation_token) { try { const tokenData = ResponseUtils.parseContinuationToken(args.continuation_token); query = tokenData.q; offset = tokenData.o; // Keep the original limit if not overridden if (!args.limit) requestedLimit = 20; } catch (error) { throw new McpError(ErrorCode.InvalidRequest, `Invalid continuation token: ${error}`); } } else if (args.query) { query = args.query; } else { throw new McpError(ErrorCode.InvalidRequest, 'Either query or continuation_token is required'); } // Get total count first const totalResults = await (this.store as any).searchContentCount ? await (this.store as any).searchContentCount(query) : null; // For dynamic pagination, we need to get more results than requested to calculate proper limits const batchSize = Math.min(requestedLimit * 3, 150); // Get up to 3x requested or max 150 const results = await (this.store as any).searchContent(query, batchSize, offset); // Use actual result count if we don't have a count method const actualTotal = totalResults !== null ? totalResults : (offset + results.length + (results.length === batchSize ? 1 : 0)); // Create dynamic paginated response that respects 22K token limit const tokenSafeResponse = ResponseUtils.createDynamicPaginatedResponse( results, query, requestedLimit, offset, actualTotal, this.domainInfo.toolPrefix, 'search_content' ); return ResponseUtils.formatMcpResponse(tokenSafeResponse); } private async handleGetPageSection(args: any) { const { path, section } = args; if (!path || typeof path !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'Path is required and must be a string'); } const page = await this.store.getPage(path); if (!page) { throw new McpError(ErrorCode.InvalidRequest, `Page not found: ${path}`); } let extractedContent: string | undefined; if (section) { // Extract specific section from markdown extractedContent = this.extractSection(page.markdown || page.content, section); if (!extractedContent) { throw new McpError(ErrorCode.InvalidRequest, `Section not found in page: ${section}`); } } const tokenSafeResponse = ResponseUtils.createPageSectionResponse(page, section, extractedContent); return ResponseUtils.formatMcpResponse(tokenSafeResponse); } private extractSection(content: string, sectionId: string): string | undefined { if (!content) return undefined; // Try to match by heading text or ID const lines = content.split('\n'); let startIndex = -1; let endIndex = lines.length; let currentLevel = 0; // Find section start for (let i = 0; i < lines.length; i++) { const line = lines[i]; const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); if (headingMatch) { const level = headingMatch[1].length; const text = headingMatch[2].trim(); const id = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); // Check if this matches our section if (text.toLowerCase().includes(sectionId.toLowerCase()) || id === sectionId.toLowerCase() || sectionId.toLowerCase().includes(text.toLowerCase())) { startIndex = i; currentLevel = level; break; } } } if (startIndex === -1) return undefined; // Find section end (next heading of same or higher level) for (let i = startIndex + 1; i < lines.length; i++) { const line = lines[i]; const headingMatch = line.match(/^(#{1,6})\s+/); if (headingMatch && headingMatch[1].length <= currentLevel) { endIndex = i; break; } } return lines.slice(startIndex, endIndex).join('\n'); } private async handleGetPageOutline(args: any) { const { path } = args; if (!path || typeof path !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'Path is required and must be a string'); } const page = await this.store.getPage(path); if (!page) { throw new McpError(ErrorCode.InvalidRequest, `Page not found: ${path}`); } const tokenSafeResponse = ResponseUtils.createPageOutlineResponse(page); return ResponseUtils.formatMcpResponse(tokenSafeResponse); } private async handleGetPage(args: any) { const { path } = args; if (!path || typeof path !== 'string') { throw new McpError(ErrorCode.InvalidParams, "Path is required and must be a string"); } const page = await this.store.getPage(path); if (!page) { throw new McpError(ErrorCode.InvalidParams, `Page not found: ${path}`); } // Use token-safe response formatting const tokenSafeResponse = ResponseUtils.createPageSectionResponse(page); return ResponseUtils.formatMcpResponse(tokenSafeResponse); } private async handleSearchCode(args: any) { const { query, language, path, limit = 10 } = args; if (!query || typeof query !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'Query is required and must be a string'); } const requestedLimit = Math.min(limit, 30); let codeResults: any[] = []; if (path) { // Search within specific page const page = await this.store.getPage(path); if (!page) { throw new McpError(ErrorCode.InvalidRequest, `Page not found: ${path}`); } codeResults = this.searchCodeInPage(page, query, language); } else { // Search across all pages codeResults = await this.searchCodeGlobally(query, requestedLimit * 2, language); } // Use dynamic pagination to respect token limits const dynamicLimit = ResponseUtils.calculateDynamicLimit(codeResults); const limitedResults = codeResults.slice(0, dynamicLimit); const response = { query, language, path, results: limitedResults, tokenManagement: { requestedLimit, actualLimit: dynamicLimit, reason: dynamicLimit < requestedLimit ? 'Reduced to stay within 22K token limit' : 'Within token limits', totalAvailable: codeResults.length }, summary: { total: codeResults.length, showing: limitedResults.length, languages: [...new Set(limitedResults.map((r: any) => r.codeBlock?.language || 'unknown'))], pages: [...new Set(limitedResults.map((r: any) => r.page?.path || 'unknown'))] } }; const jsonString = JSON.stringify(response, null, 2); const estimatedTokens = ResponseUtils.estimateTokens(jsonString); return ResponseUtils.formatMcpResponse({ content: response, tokenInfo: { estimated: estimatedTokens, safe: estimatedTokens < 22000, truncated: dynamicLimit < codeResults.length } }); } private searchCodeInPage(page: any, query: string, language?: string): any[] { if (!page.codeBlocks || page.codeBlocks.length === 0) return []; const results: any[] = []; const queryLower = query.toLowerCase(); page.codeBlocks.forEach((block: any, index: number) => { // Filter by language if specified if (language && block.language.toLowerCase() !== language.toLowerCase()) { return; } // Search in code content const codeLines = block.code.split('\n'); const matchingLines: { lineNumber: number; line: string }[] = []; codeLines.forEach((line: string, lineIndex: number) => { if (line.toLowerCase().includes(queryLower)) { matchingLines.push({ lineNumber: lineIndex + 1, line: line.trim() }); } }); // Also check title and language const titleMatch = block.title && block.title.toLowerCase().includes(queryLower); const languageMatch = block.language.toLowerCase().includes(queryLower); if (matchingLines.length > 0 || titleMatch || languageMatch) { results.push({ page: { title: page.title, path: page.path, section: page.section }, codeBlock: { index: index + 1, language: block.language, title: block.title, lineCount: codeLines.length, matches: matchingLines.slice(0, 5), // Limit matches shown matchType: titleMatch ? 'title' : languageMatch ? 'language' : 'content', preview: codeLines.slice(0, 3).join('\n') + (codeLines.length > 3 ? '\n...' : '') } }); } }); return results; } private async searchCodeGlobally(query: string, limit: number, language?: string): Promise<any[]> { // Simplified global search for HTTP version const allResults: any[] = []; try { // Get a limited set of pages to search const pages = await (this.store as any).getSamplePages ? await (this.store as any).getSamplePages(20) : []; for (const page of pages) { const pageResults = this.searchCodeInPage(page, query, language); allResults.push(...pageResults); // Early exit if we have enough results if (allResults.length >= limit * 2) break; } } catch (error) { console.error('Error in global code search:', error); } // Sort by relevance return allResults.sort((a, b) => { if (a.codeBlock.matchType === 'title' && b.codeBlock.matchType !== 'title') return -1; if (b.codeBlock.matchType === 'title' && a.codeBlock.matchType !== 'title') return 1; return b.codeBlock.matches.length - a.codeBlock.matches.length; }); } private async handleGetRelatedPages(args: any) { const { path, topic, limit = 5 } = args; const maxLimit = Math.min(limit, 15); let relatedPages: any[] = []; if (path) { // Find pages related to a specific page const page = await this.store.getPage(path); if (!page) { throw new McpError(ErrorCode.InvalidRequest, `Page not found: ${path}`); } relatedPages = await this.findRelatedToPage(page, maxLimit); } else if (topic) { // Find pages related to a topic relatedPages = await this.findRelatedToTopic(topic, maxLimit); } else { throw new McpError(ErrorCode.InvalidRequest, 'Either path or topic is required'); } const response = { relatedTo: path ? { type: 'page', path } : { type: 'topic', topic }, results: relatedPages, summary: { total: relatedPages.length, sections: [...new Set(relatedPages.map(p => p.section))], avgRelevanceScore: relatedPages.length > 0 ? relatedPages.reduce((sum, p) => sum + (p.relevanceScore || 0), 0) / relatedPages.length : 0 } }; const jsonString = JSON.stringify(response, null, 2); const estimatedTokens = ResponseUtils.estimateTokens(jsonString); return ResponseUtils.formatMcpResponse({ content: response, tokenInfo: { estimated: estimatedTokens, safe: estimatedTokens < 20000, truncated: false } }); } private async findRelatedToPage(page: any, limit: number): Promise<any[]> { const relatedPages: any[] = []; try { // Strategy 1: Pages in the same section if (page.section) { const sectionPages = await (this.store as any).getSectionPages ? await (this.store as any).getSectionPages(page.section) : []; sectionPages.forEach((p: any) => { if (p.path !== page.path) { relatedPages.push({ ...p, relevanceScore: 0.8, relationshipType: 'same_section' }); } }); } // Strategy 2: Simple search-based similarity const keywords = this.extractKeywords(page.title + ' ' + (page.content || '').substring(0, 500)); if (keywords.length > 0) { const searchResults = await (this.store as any).searchContent ? await (this.store as any).searchContent(keywords.slice(0, 3).join(' '), limit * 2) : []; searchResults.forEach((result: any) => { if (result.page.path !== page.path && !relatedPages.find(p => p.path === result.page.path)) { relatedPages.push({ ...result.page, relevanceScore: 0.6, relationshipType: 'content_similarity', snippet: result.snippet }); } }); } } catch (error) { console.error('Error finding related pages:', error); } // Sort by relevance score and limit results return relatedPages .sort((a, b) => (b.relevanceScore || 0) - (a.relevanceScore || 0)) .slice(0, limit); } private async findRelatedToTopic(topic: string, limit: number): Promise<any[]> { try { const searchResults = await (this.store as any).searchContent ? await (this.store as any).searchContent(topic, limit * 2) : []; return searchResults.slice(0, limit).map((result: any) => ({ ...result.page, relevanceScore: 0.7, relationshipType: 'topic_match', snippet: result.snippet })); } catch (error) { console.error('Error finding pages for topic:', error); return []; } } private extractKeywords(text: string): string[] { if (!text) return []; // Simple keyword extraction - remove common words and get important terms const commonWords = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'up', 'about', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'between', 'among', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'can', 'this', 'that', 'these', 'those']); return text .toLowerCase() .replace(/[^\w\s]/g, ' ') .split(/\s+/) .filter(word => word.length > 3 && !commonWords.has(word)) .slice(0, 10); // Limit to top 10 keywords } private async handleListSections() { const sections = await this.store.getSections(); return { content: [ { type: "text", text: `Available sections in ${this.domainInfo.name}:\n\n` + sections.map((section, index) => `${index + 1}. ${section}`).join('\n') } ] }; } private async handleGetSectionPages(args: any) { const { section } = args; if (!section || typeof section !== 'string') { throw new McpError(ErrorCode.InvalidParams, "Section is required and must be a string"); } const pages = await this.store.getPagesBySection(section); if (pages.length === 0) { throw new McpError(ErrorCode.InvalidParams, `No pages found in section: ${section}`); } return { content: [ { type: "text", text: `Pages in section "${section}":\n\n` + pages.map((page, index) => `${index + 1}. **${page.title}**\n Path: ${page.path}\n URL: ${page.url}\n` ).join('\n') } ] }; } private async handleRefreshContent() { await this.scraper.scrapeAll(); const content = this.scraper.getContent(); await this.store.updateContent(content); const stats = await this.store.getStats(); return { content: [ { type: "text", text: `Content refreshed successfully!\n\nStats:\n- Total pages: ${stats.totalPages}\n- Last updated: ${stats.lastUpdated}` } ] }; } private async handleGetCodeBlocks(args: any) { const { path } = args; if (!path || typeof path !== 'string') { throw new McpError(ErrorCode.InvalidParams, "Path is required and must be a string"); } const page = await this.store.getPage(path); if (!page) { throw new McpError(ErrorCode.InvalidParams, `Page not found: ${path}`); } if (page.codeBlocks.length === 0) { return { content: [ { type: "text", text: `No code blocks found in page: ${path}` } ] }; } return { content: [ { type: "text", text: `Code blocks from "${page.title}":\n\n` + page.codeBlocks.map((block, index) => `**Block ${index + 1}** (${block.language})${block.title ? ` - ${block.title}` : ''}:\n\`\`\`${block.language}\n${block.code}\n\`\`\`\n` ).join('\n') } ] }; } private async handleGetMarkdown(args: any) { const { path } = args; if (!path || typeof path !== 'string') { throw new McpError(ErrorCode.InvalidParams, "Path is required and must be a string"); } const page = await this.store.getPage(path); if (!page) { throw new McpError(ErrorCode.InvalidParams, `Page not found: ${path}`); } return { content: [ { type: "text", text: page.markdown } ] }; } // Simplified prompt handlers private async handleExplainSection(args: any) { const { section } = args; const pages = await this.store.getPagesBySection(section); return { description: `Comprehensive explanation of the ${section} section`, messages: [ { role: "user", content: { type: "text", text: `Based on the ${this.domainInfo.name} documentation, provide a comprehensive explanation and tutorial for the "${section}" section. Here are the pages in this section:\n\n` + pages.map(p => `- ${p.title} (${p.path}): ${p.content.substring(0, 200)}...`).join('\n\n') } } ] }; } private async handleSummarizePage(args: any) { const { path } = args; const page = await this.store.getPage(path); if (!page) { throw new McpError(ErrorCode.InvalidParams, `Page not found: ${path}`); } return { description: `Summary of ${page.title}`, messages: [ { role: "user", content: { type: "text", text: `Create a concise summary of this page from ${this.domainInfo.name}:\n\n**${page.title}**\n\n${page.content}` } } ] }; } private async handleCompareSections(args: any) { const { section1, section2 } = args; const pages1 = await this.store.getPagesBySection(section1); const pages2 = await this.store.getPagesBySection(section2); return { description: `Comparison between ${section1} and ${section2} sections`, messages: [ { role: "user", content: { type: "text", text: `Compare and contrast these two sections from ${this.domainInfo.name}:\n\n**${section1}:**\n` + pages1.map(p => `- ${p.title}: ${p.content.substring(0, 150)}...`).join('\n') + `\n\n**${section2}:**\n` + pages2.map(p => `- ${p.title}: ${p.content.substring(0, 150)}...`).join('\n') } } ] }; } private async handleApiReference(args: any) { const { path } = args; const page = await this.store.getPage(path); if (!page) { throw new McpError(ErrorCode.InvalidParams, `Page not found: ${path}`); } return { description: `API reference for ${page.title}`, messages: [ { role: "user", content: { type: "text", text: `Format this content from ${this.domainInfo.name} as a structured API reference with parameters, examples, and usage information:\n\n**${page.title}**\n\n${page.content}` } } ] }; } private async handleQuickStartGuide(args: any) { const { topic } = args; const results = await this.store.search(topic, 10); return { description: `Quick start guide for ${topic}`, messages: [ { role: "user", content: { type: "text", text: `Create a quick start guide for "${topic}" based on this content from ${this.domainInfo.name}:\n\n` + results.map(r => `**${r.page.title}**\n${r.page.content.substring(0, 300)}...`).join('\n\n') } } ] }; } // Helper method to create a new server instance private createServerInstance(): Server { const server = new Server( { name: this.domainInfo.name, version: gitBookConfig.serverVersion, }, { capabilities: { tools: { listChanged: true }, prompts: { listChanged: true } } } ); // Set up all the handlers (copy from constructor) this.setupHandlersForServer(server); return server; } private setupHandlersForServer(server: Server) { server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: `${this.domainInfo.toolPrefix}search_content`, description: `Search across all ${this.domainInfo.description} for ${this.domainInfo.keywords.slice(0, 5).join(', ')}`, inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query', }, continuation_token: { type: 'string', description: 'Continuation token for paginated results', }, limit: { type: 'number', description: 'Maximum number of results to return (default: 20, max: 50)', minimum: 1, maximum: 50, }, offset: { type: 'number', description: 'Number of results to skip for pagination (default: 0)', minimum: 0, }, }, required: [], }, }, { name: `${this.domainInfo.toolPrefix}get_page_section`, description: `Get a specific section from a page in ${this.domainInfo.description}`, inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Page path (e.g., "/sdk/websdk")', }, section: { type: 'string', description: 'Section identifier or heading text', }, }, required: ['path'], }, }, { name: `${this.domainInfo.toolPrefix}get_page_outline`, description: `Get the structure and outline of a specific page in ${this.domainInfo.description}`, inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Page path (e.g., "/sdk/websdk")', }, }, required: ['path'], }, }, { name: `${this.domainInfo.toolPrefix}get_page`, description: `Get a specific page from ${this.domainInfo.name} by path`, inputSchema: { type: "object", properties: { path: { type: "string", description: "Page path (e.g., '/api/authentication' or '/sdk/quickstart')" } }, required: ["path"] } }, { name: `${this.domainInfo.toolPrefix}search_code`, description: `Search for code blocks and programming examples in ${this.domainInfo.description}`, inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query for code (language, function, keyword, etc.)', }, language: { type: 'string', description: 'Filter by programming language (e.g., "javascript", "python")', }, path: { type: 'string', description: 'Optional: search within specific page path', }, limit: { type: 'number', description: 'Maximum number of code blocks to return (default: 10, max: 30)', minimum: 1, maximum: 30, }, }, required: ['query'], }, }, { name: `${this.domainInfo.toolPrefix}get_related_pages`, description: `Find pages related to a specific page or topic in ${this.domainInfo.description}`, inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Page path to find related pages for', }, topic: { type: 'string', description: 'Topic or keyword to find related pages for', }, limit: { type: 'number', description: 'Maximum number of related pages to return (default: 5, max: 15)', minimum: 1, maximum: 15, }, }, required: [], }, }, { name: `${this.domainInfo.toolPrefix}list_sections`, description: `Get the table of contents for ${this.domainInfo.description}`, inputSchema: { type: "object", properties: {}, required: [] } }, { name: `${this.domainInfo.toolPrefix}get_section_pages`, description: `Get all pages in a specific section of ${this.domainInfo.name}`, inputSchema: { type: "object", properties: { section: { type: "string", description: "Section name (e.g., 'API Reference' or 'Getting Started')" } }, required: ["section"] } }, { name: `${this.domainInfo.toolPrefix}refresh_content`, description: `Force refresh the cached content from ${this.domainInfo.name}`, inputSchema: { type: "object", properties: {}, required: [] } }, { name: `${this.domainInfo.toolPrefix}get_code_blocks`, description: `Extract all code blocks from a specific page with syntax highlighting`, inputSchema: { type: "object", properties: { path: { type: "string", description: "Page path (e.g., '/api/authentication')" } }, required: ["path"] } }, { name: `${this.domainInfo.toolPrefix}get_markdown`, description: `Get a page's content formatted as clean markdown`, inputSchema: { type: "object", properties: { path: { type: "string", description: "Page path (e.g., '/api/authentication')" } }, required: ["path"] } } ] }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case `${this.domainInfo.toolPrefix}search_content`: return await this.handleSearchContent(args); case `${this.domainInfo.toolPrefix}get_page_section`: return await this.handleGetPageSection(args); case `${this.domainInfo.toolPrefix}get_page_outline`: return await this.handleGetPageOutline(args); case `${this.domainInfo.toolPrefix}get_page`: return await this.handleGetPage(args); case `${this.domainInfo.toolPrefix}search_code`: return await this.handleSearchCode(args); case `${this.domainInfo.toolPrefix}get_related_pages`: return await this.handleGetRelatedPages(args); case `${this.domainInfo.toolPrefix}list_sections`: return await this.handleListSections(); case `${this.domainInfo.toolPrefix}get_section_pages`: return await this.handleGetSectionPages(args); case `${this.domainInfo.toolPrefix}refresh_content`: return await this.handleRefreshContent(); case `${this.domainInfo.toolPrefix}get_code_blocks`: return await this.handleGetCodeBlocks(args); case `${this.domainInfo.toolPrefix}get_markdown`: return await this.handleGetMarkdown(args); default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${name}` ); } } catch (error) { if (error instanceof McpError) { throw error; } throw new McpError( ErrorCode.InternalError, `Error executing tool ${name}: ${error instanceof Error ? error.message : String(error)}` ); } }); // Add prompt handlers server.setRequestHandler(ListPromptsRequestSchema, async () => { return { prompts: [ { name: "explain_section", description: `Generate a comprehensive explanation or tutorial for a specific section of ${this.domainInfo.name}`, arguments: [ { name: "section", description: "The section name to explain", required: true } ] }, { name: "summarize_page", description: `Create a concise summary of a specific page from ${this.domainInfo.name}`, arguments: [ { name: "path", description: "The page path to summarize", required: true } ] }, { name: "compare_sections", description: `Compare and contrast different sections of ${this.domainInfo.name}`, arguments: [ { name: "section1", description: "First section to compare", required: true }, { name: "section2", description: "Second section to compare", required: true } ] }, { name: "api_reference", description: `Format content from ${this.domainInfo.name} as a structured API reference`, arguments: [ { name: "path", description: "The page path containing API information", required: true } ] }, { name: "quick_start_guide", description: `Generate a quick start guide based on ${this.domainInfo.name} content`, arguments: [ { name: "topic", description: "The topic or feature to create a quick start guide for", required: true } ] } ] }; }); server.setRequestHandler(GetPromptRequestSchema, async (request) => { const { name, arguments: args } = request.params; switch (name) { case "explain_section": return await this.handleExplainSection(args); case "summarize_page": return await this.handleSummarizePage(args); case "compare_sections": return await this.handleCompareSections(args); case "api_reference": return await this.handleApiReference(args); case "quick_start_guide": return await this.handleQuickStartGuide(args); default: throw new McpError( ErrorCode.MethodNotFound, `Unknown prompt: ${name}` ); } }); } async run(port: number = 3001) { // Initialize content first await this.initializeContent(); // Create Express app const app = express(); app.use(cors()); app.use(express.json({ limit: '10mb' })); // MCP StreamableHTTP handler following SDK example pattern const mcpHandler = async (req: express.Request, res: express.Response) => { const sessionId = req.headers['mcp-session-id'] as string; try { let transport: StreamableHTTPServerTransport; if (sessionId && this.transports[sessionId]) { // Reuse existing transport for this session transport = this.transports[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { // New initialization request - create new transport and server transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId: string) => { console.error(`StreamableHTTP session initialized: ${sessionId}`); this.transports[sessionId] = transport; }, onsessionclosed: (sessionId: string) => { console.error(`StreamableHTTP session closed: ${sessionId}`); delete this.transports[sessionId]; } }); // Set up cleanup when transport closes transport.onclose = () => { const sid = transport.sessionId; if (sid && this.transports[sid]) { console.error(`Transport closed for session ${sid}`); delete this.transports[sid]; } }; // Create a new server instance and connect it to the transport const serverInstance = this.createServerInstance(); await serverInstance.connect(transport); await transport.handleRequest(req, res, req.body); return; } else { // Invalid request - no session ID or not initialization request res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: No valid session ID provided', }, id: null, }); return; } // Handle request with existing transport await transport.handleRequest(req, res, req.body); } catch (error) { console.error('StreamableHTTP error:', error); if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', }, id: null, }); } } }; // Support both root path and /mcp for compatibility app.post('/', mcpHandler); app.delete('/', mcpHandler); app.get('/', mcpHandler); app.post('/mcp', mcpHandler); app.delete('/mcp', mcpHandler); app.get('/mcp', mcpHandler); // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'healthy', server: this.domainInfo.name, version: gitBookConfig.serverVersion, transport: 'StreamableHTTP' }); }); app.listen(port, () => { console.error(`${this.domainInfo.name} v${gitBookConfig.serverVersion} running on StreamableHTTP`); console.error(`Server listening on port ${port}`); console.error(`Health check: http://localhost:${port}/health`); console.error(`MCP endpoint: http://localhost:${port}/mcp`); console.error(`Loaded content from: ${gitBookConfig.gitbookUrl}`); console.error(`Detected domain: ${this.domainInfo.description}`); console.error(`Keywords: ${this.domainInfo.keywords.join(', ')}`); }); // Graceful shutdown process.on('SIGINT', async () => { console.error('Shutting down StreamableHTTP server...'); for (const sessionId in this.transports) { try { await this.transports[sessionId].close(); delete this.transports[sessionId]; } catch (error) { console.error(`Error closing session ${sessionId}:`, error); } } process.exit(0); }); } private async initializeContent(): Promise<void> { // Try to migrate from JSON cache first const jsonCachePath = getCacheFilePath(gitBookConfig.gitbookUrl).replace('.db', '.json'); await this.store.importFromJson(jsonCachePath); // Check if we have content or need to scrape const pageCount = await this.store.getPageCount(); if (pageCount === 0) { console.error('No cached content found, running initial scrape...'); await this.scraper.scrapeAll(); const content = this.scraper.getContent(); await this.store.updateContent(content); // Detect domain after initial scraping this.domainInfo = DomainDetector.detectDomain(content, gitBookConfig.gitbookUrl); } else { console.error(`Loaded ${pageCount} pages from SQLite cache`); // For cached content, detect domain from stored pages const pages = await this.store.getAllPages(); const content = pages.reduce((acc, page) => { acc[page.path] = page; return acc; }, {} as any); this.domainInfo = DomainDetector.detectDomain(content, gitBookConfig.gitbookUrl); // Run background update check (non-blocking) this.checkForUpdatesBackground(); } } private async checkForUpdatesBackground(): Promise<void> { // Run update check in background, don't block startup setTimeout(async () => { try { console.error('Running background update check...'); await this.scraper.scrapeAll(); const content = this.scraper.getContent(); if (Object.keys(content).length > 0) { await this.store.updateContent(content); console.error('Background update completed'); } } catch (error) { console.error('Background update failed:', error); } }, 1000); } } // Run if this file is executed directly if (require.main === module) { const port = process.env.MCP_HTTP_PORT ? parseInt(process.env.MCP_HTTP_PORT) : 3001; const server = new GitBookMCPHttpServer(); server.run(port).catch(console.error); } export { GitBookMCPHttpServer };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/tcsenpai/mcpbook'

If you have feedback or need assistance with the MCP directory API, please join our Discord server