Skip to main content
Glama
Jameswlepage

WordPress Trac MCP Server

by Jameswlepage
index.ts68.1 kB
import { z } from "zod"; // JSON-RPC 2.0 message schemas const JsonRpcRequestSchema = z.object({ jsonrpc: z.literal("2.0"), method: z.string(), params: z.any().optional(), id: z.union([z.string(), z.number()]).optional(), }); // In-memory cache for ChatGPT fetch operations const chatgptCache = new Map<string, any>(); /** * Handle MCP JSON-RPC 2.0 requests */ async function handleMcpRequest(request: any): Promise<any> { const { method, params, id } = request; switch (method) { case "initialize": return { jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {}, prompts: {}, }, serverInfo: { name: "WordPress Trac", version: "1.0.0", }, }, }; case "tools/list": return { jsonrpc: "2.0", id, result: { tools: [ { name: "searchTickets", description: "Search for WordPress Trac tickets by keyword or filter expression. Returns ticket summaries with basic info.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query for tickets (keywords or filter expressions like 'summary~=keyword')", }, limit: { type: "number", description: "Maximum number of results to return (default: 10, max: 50)", default: 10, }, status: { type: "string", description: "Filter by ticket status (e.g., 'open', 'closed', 'new')", }, component: { type: "string", description: "Filter by component name (e.g., 'Administration', 'Posts, Post Types')", }, }, required: ["query"], }, }, { name: "getTicket", description: "Get detailed information about a specific WordPress Trac ticket including description, comments, and metadata.", inputSchema: { type: "object", properties: { id: { type: "number", description: "Trac ticket ID number", }, includeComments: { type: "boolean", description: "Include ticket comments and discussion (default: true)", default: true, }, commentLimit: { type: "number", description: "Maximum number of comments to return (default: 10, max: 50)", default: 10, }, }, required: ["id"], }, }, { name: "getChangeset", description: "Get information about a specific WordPress code changeset/commit including commit message, author, and diff.", inputSchema: { type: "object", properties: { revision: { type: "number", description: "SVN revision number (e.g., 58504)", }, includeDiff: { type: "boolean", description: "Include diff content (default: true)", default: true, }, diffLimit: { type: "number", description: "Maximum characters of diff to return (default: 2000, max: 10000)", default: 2000, }, }, required: ["revision"], }, }, { name: "getTimeline", description: "Get recent activity from WordPress Trac timeline including recent tickets, commits, and other events.", inputSchema: { type: "object", properties: { days: { type: "number", description: "Number of days to look back (default: 7, max: 30)", default: 7, }, limit: { type: "number", description: "Maximum number of events to return (default: 20, max: 100)", default: 20, }, }, }, }, { name: "getTracInfo", description: "Get WordPress Trac metadata like components, milestones, priorities, and severities.", inputSchema: { type: "object", properties: { type: { type: "string", enum: ["components", "milestones", "priorities", "severities"], description: "Type of Trac information to retrieve", }, }, required: ["type"], }, }, ], }, }; case "tools/call": const { name, arguments: args } = params; try { let result; switch (name) { case "searchTickets": { const { query, limit = 10, status, component } = args; try { // Build Trac query URL const queryUrl = new URL('https://core.trac.wordpress.org/query'); queryUrl.searchParams.set('format', 'csv'); queryUrl.searchParams.set('max', Math.min(limit, 50).toString()); // Add keyword search if (query.includes('=') || query.includes('~')) { // User provided a direct filter queryUrl.searchParams.set('summary', query); } else { // Search in summary with keyword queryUrl.searchParams.set('summary', `~${query}`); } // Add status filter if (status) { queryUrl.searchParams.set('status', status); } // Add component filter if (component) { queryUrl.searchParams.set('component', component); } // Query tickets with proper headers const response = await fetch(queryUrl.toString(), { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WordPress-Trac-MCP-Server/1.0)', 'Accept': 'text/csv,text/plain,*/*', 'Accept-Language': 'en-US,en;q=0.9', } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const csvData = await response.text(); // Check if we got HTML instead of CSV (403 error) if (csvData.includes('<html>') || csvData.includes('403 Forbidden')) { // Fallback: try without search parameters const fallbackUrl = new URL('https://core.trac.wordpress.org/query'); fallbackUrl.searchParams.set('format', 'csv'); fallbackUrl.searchParams.set('max', Math.min(limit * 3, 100).toString()); // Get more to filter const fallbackResponse = await fetch(fallbackUrl.toString(), { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WordPress-Trac-MCP-Server/1.0)', 'Accept': 'text/csv,text/plain,*/*', 'Accept-Language': 'en-US,en;q=0.9', } }); if (!fallbackResponse.ok) { throw new Error(`Fallback query failed: ${fallbackResponse.status} ${fallbackResponse.statusText}`); } const fallbackData = await fallbackResponse.text(); if (fallbackData.includes('<html>') || fallbackData.includes('403 Forbidden')) { throw new Error('Access denied - both search and fallback queries returned HTML'); } // Filter results client-side const allLines = fallbackData.trim().split('\n'); const filteredLines = [allLines[0]]; // Keep header for (let i = 1; i < allLines.length && i < allLines.length; i++) { const line = allLines[i]; if (line && line.toLowerCase().includes(query.toLowerCase())) { filteredLines.push(line); if (filteredLines.length > limit) break; } } const result = { csvData: filteredLines.join('\n'), wasFiltered: true }; // Parse CSV data const lines = result.csvData.trim().split('\n'); if (lines.length < 2) { throw new Error('No tickets found matching search criteria'); } const tickets = []; for (let i = 1; i < lines.length; i++) { const line = lines[i]?.trim(); if (!line) continue; // Simple CSV parsing - handle quoted fields const values = []; let currentField = ''; let inQuotes = false; for (let j = 0; j < line.length; j++) { const char = line[j]; if (char === '"' && (j === 0 || line[j-1] === ',')) { inQuotes = true; } else if (char === '"' && inQuotes && (j === line.length - 1 || line[j+1] === ',')) { inQuotes = false; } else if (char === ',' && !inQuotes) { values.push(currentField.trim()); currentField = ''; } else { currentField += char; } } values.push(currentField.trim()); if (values.length >= 2 && values[0] && !isNaN(parseInt(values[0]))) { const ticket = { id: parseInt(values[0]), title: values[1] || '', text: `#${values[0]}: ${values[1] || 'No summary'}\nStatus: ${values[2] || 'unknown'}\nOwner: ${values[3] || 'unassigned'}\nType: ${values[4] || 'unknown'}\nPriority: ${values[5] || 'unknown'}\nMilestone: ${values[6] || 'none'}`, url: `https://core.trac.wordpress.org/ticket/${values[0]}`, metadata: { status: values[2] || 'unknown', owner: values[3] || 'unassigned', type: values[4] || 'unknown', priority: values[5] || 'unknown', milestone: values[6] || 'none', }, }; tickets.push(ticket); } } return { results: tickets, query, totalFound: tickets.length, returned: tickets.length, note: result.wasFiltered ? 'Results filtered client-side due to search API limitations' : undefined, }; } const queryResult = { csvData, wasFiltered: false }; // Parse CSV data const lines = queryResult.csvData.trim().split('\n'); if (lines.length < 2) { throw new Error('No tickets found or invalid CSV response'); } const tickets = []; for (let i = 1; i < lines.length; i++) { const line = lines[i]?.trim(); if (!line) continue; // Simple CSV parsing - handle quoted fields const values = []; let currentField = ''; let inQuotes = false; for (let j = 0; j < line.length; j++) { const char = line[j]; if (char === '"' && (j === 0 || line[j-1] === ',')) { inQuotes = true; } else if (char === '"' && inQuotes && (j === line.length - 1 || line[j+1] === ',')) { inQuotes = false; } else if (char === ',' && !inQuotes) { values.push(currentField.trim()); currentField = ''; } else { currentField += char; } } values.push(currentField.trim()); if (values.length >= 2 && values[0] && !isNaN(parseInt(values[0]))) { const ticket = { id: parseInt(values[0]), title: values[1] || '', text: `#${values[0]}: ${values[1] || 'No summary'}\nStatus: ${values[4] || 'unknown'}\nOwner: ${values[2] || 'unassigned'}\nType: ${values[3] || 'unknown'}\nPriority: ${values[5] || 'unknown'}\nMilestone: ${values[6] || 'none'}`, url: `https://core.trac.wordpress.org/ticket/${values[0]}`, metadata: { status: values[4] || 'unknown', owner: values[2] || 'unassigned', type: values[3] || 'unknown', priority: values[5] || 'unknown', milestone: values[6] || 'none', }, }; tickets.push(ticket); } } result = { results: tickets, query, totalFound: tickets.length, returned: tickets.length, note: queryResult.wasFiltered ? 'Results filtered client-side due to search API limitations' : undefined, }; } catch (error) { result = { results: [], query, error: error instanceof Error ? error.message : 'Unknown error', }; } break; } case "getTicket": { const { id, includeComments = true } = args; try { // Use search approach since CSV parsing is problematic const searchUrl = new URL('https://core.trac.wordpress.org/query'); searchUrl.searchParams.set('format', 'csv'); searchUrl.searchParams.set('id', id.toString()); searchUrl.searchParams.set('max', '1'); const response = await fetch(searchUrl.toString(), { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WordPress-Trac-MCP-Server/1.0)', 'Accept': 'text/csv,text/plain,*/*', } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const csvData = await response.text(); // Parse CSV data similar to searchTickets const lines = csvData.replace(/^\uFEFF/, '').trim().split(/\r?\n/); if (lines.length < 2) { throw new Error(`Ticket ${id} not found`); } // Parse each line like in searchTickets for (let i = 1; i < lines.length; i++) { const line = lines[i]?.trim(); if (!line) continue; // Better CSV parsing - handle quoted fields properly const values = []; let currentField = ''; let inQuotes = false; let escapeNext = false; for (let j = 0; j < line.length; j++) { const char = line[j]; if (escapeNext) { currentField += char; escapeNext = false; continue; } if (char === '\\') { escapeNext = true; continue; } if (char === '"') { if (inQuotes) { // Check if this is an escaped quote if (j + 1 < line.length && line[j + 1] === '"') { currentField += '"'; j++; // Skip the next quote } else { inQuotes = false; } } else { inQuotes = true; } } else if (char === ',' && !inQuotes) { values.push(currentField.trim()); currentField = ''; } else { currentField += char; } } values.push(currentField.trim()); if (values.length >= 2 && values[0] && !isNaN(parseInt(values[0]))) { const ticketId = parseInt(values[0]); if (ticketId === id) { // Map fields based on actual headers from search query // Headers: id,Summary,Owner,Type,Status,Priority,Milestone const ticket = { id: parseInt(values[0]), summary: values[1] || '', owner: values[2] || '', type: values[3] || '', status: values[4] || '', priority: values[5] || '', milestone: values[6] || '', reporter: '', // Not available in search query description: 'Full description not available in search query. Visit the ticket URL for complete details.', component: '', // Not available in search query version: '', severity: '', resolution: '', keywords: '', cc: '', focuses: '', }; // Note: Comments are not available through the CSV API let comments: any[] = []; if (includeComments) { comments = [{ author: 'system', timestamp: new Date().toISOString(), comment: 'Comment history not available through CSV API. Visit the ticket URL for full discussion.', }]; } result = { id: id, title: `#${id}: ${ticket.summary}`, text: `Ticket #${id}: ${ticket.summary}\n\nStatus: ${ticket.status}\nComponent: ${ticket.component}\nPriority: ${ticket.priority}\nType: ${ticket.type}\nReporter: ${ticket.reporter}\nOwner: ${ticket.owner}\nMilestone: ${ticket.milestone}\nVersion: ${ticket.version}\nKeywords: ${ticket.keywords}\n\nDescription:\n${ticket.description}\n\nFor full discussion and comments, visit: https://core.trac.wordpress.org/ticket/${id}`, url: `https://core.trac.wordpress.org/ticket/${id}`, metadata: { ticket, comments, totalComments: comments.length, }, }; break; // Found the ticket, exit the loop } } } // If we didn't find the ticket, result will be undefined if (!result) { throw new Error(`Ticket ${id} not found`); } } catch (error) { result = { id: id, title: `Error loading ticket ${id}`, text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, url: `https://core.trac.wordpress.org/ticket/${id}`, metadata: { error: true }, }; } break; } case "getChangeset": { const { revision, includeDiff = true, diffLimit = 2000 } = args; try { const changesetUrl = `https://core.trac.wordpress.org/changeset/${revision}`; // Fetch changeset page const response = await fetch(changesetUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } }); if (!response.ok) { throw new Error(`Changeset ${revision} not found`); } const html = await response.text(); // Parse changeset information from HTML with improved patterns const messageMatch = html.match(/<dd class="message[^"]*"[^>]*>\s*<p[^>]*>(.*?)<\/p>/s) || html.match(/<dd class="message[^"]*"[^>]*>(.*?)<\/dd>/s) || html.match(/<div class="message"[^>]*>\s*<p[^>]*>(.*?)<\/p>/s) || html.match(/<div class="message"[^>]*>(.*?)<\/div>/s); const authorMatch = html.match(/<dd class="author"[^>]*><span class="trac-author"[^>]*>(.*?)<\/span><\/dd>/s) || html.match(/<dt class="property author">Author:<\/dt>\s*<dd class="author">(.*?)<\/dd>/s) || html.match(/<dt>Author:<\/dt>\s*<dd>(.*?)<\/dd>/s); const dateMatch = html.match(/<dd class="date"[^>]*>(.*?)<\/dd>/s) || html.match(/<dt class="property date">Date:<\/dt>\s*<dd class="date">(.*?)<\/dd>/s) || html.match(/<dt>Date:<\/dt>\s*<dd>(.*?)<\/dd>/s); const changeset = { revision, author: authorMatch?.[1] ? authorMatch[1].replace(/<[^>]*>/g, '').trim() : '', date: dateMatch?.[1] ? dateMatch[1].replace(/<[^>]*>/g, '').trim() : '', message: messageMatch?.[1] ? messageMatch[1].replace(/<[^>]*>/g, '').trim() : '', files: [] as string[], diff: '', }; // Extract file list with improved patterns const fileMatches = html.match(/<h2[^>]*>Files:<\/h2>([\s\S]*?)<\/div>/) || html.match(/<div class="files"[^>]*>([\s\S]*?)<\/div>/) || html.match(/<div[^>]*class="[^"]*files[^"]*"[^>]*>([\s\S]*?)<\/div>/); if (fileMatches?.[1]) { const fileListHtml = fileMatches[1]; const filePathMatches = fileListHtml.match(/<a[^>]*href="[^"]*\/browser\/[^"]*"[^>]*>(.*?)<\/a>/g) || fileListHtml.match(/<a[^>]*href="[^"]*"[^>]*>(.*?)<\/a>/g) || fileListHtml.match(/<li[^>]*>(.*?)<\/li>/g); if (filePathMatches) { changeset.files = filePathMatches .map(match => match.replace(/<[^>]*>/g, '').trim()) .filter(path => path && !path.includes('(') && !path.includes('modified') && !path.includes('added') && !path.includes('deleted')) .slice(0, 20); // Limit to first 20 files } } // Get diff if requested if (includeDiff) { try { const diffUrl = `${changesetUrl}?format=diff`; const diffResponse = await fetch(diffUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } }); if (diffResponse.ok) { let diffText = await diffResponse.text(); const maxDiffLength = Math.min(diffLimit, 10000); if (diffText.length > maxDiffLength) { diffText = diffText.substring(0, maxDiffLength) + '\n... [diff truncated] ...'; } changeset.diff = diffText; } } catch (error) { console.warn('Failed to load diff:', error); } } result = { id: revision.toString(), title: `r${revision}: ${changeset.message}`, text: `Changeset r${revision}\nAuthor: ${changeset.author}\nDate: ${changeset.date}\n\nMessage:\n${changeset.message}\n\nFiles changed: ${changeset.files.length}\n${changeset.files.slice(0, 10).join('\n')}${changeset.files.length > 10 ? '\n...' : ''}\n\n${changeset.diff ? `Diff:\n${changeset.diff}` : 'No diff available'}`, url: changesetUrl, metadata: { changeset, totalFiles: changeset.files.length, }, }; } catch (error) { result = { id: revision.toString(), title: `Error loading changeset ${revision}`, text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, url: `https://core.trac.wordpress.org/changeset/${revision}`, metadata: { error: true }, }; } break; } case "getTimeline": { const { days = 7, limit = 20 } = args; try { const timelineUrl = `https://core.trac.wordpress.org/timeline?from=${days}%2Bdays+ago&max=${Math.min(limit, 100)}&format=rss`; const response = await fetch(timelineUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } }); if (!response.ok) { throw new Error(`Failed to fetch timeline: ${response.statusText}`); } const rssText = await response.text(); // Better RSS parsing with multiple pattern attempts const itemMatches = rssText.match(/<item>([\s\S]*?)<\/item>/g); const events = []; if (itemMatches) { for (const itemMatch of itemMatches) { // Try CDATA patterns first let titleMatch = itemMatch.match(/<title><!\[CDATA\[(.*?)\]\]><\/title>/s); let linkMatch = itemMatch.match(/<link>(.*?)<\/link>/s); let descMatch = itemMatch.match(/<description><!\[CDATA\[(.*?)\]\]><\/description>/s); let dateMatch = itemMatch.match(/<pubDate>(.*?)<\/pubDate>/s); let creatorMatch = itemMatch.match(/<dc:creator>(.*?)<\/dc:creator>/s); // Fallback to non-CDATA patterns if (!titleMatch) { titleMatch = itemMatch.match(/<title>(.*?)<\/title>/s); } if (!descMatch) { descMatch = itemMatch.match(/<description>(.*?)<\/description>/s); } if (titleMatch && linkMatch) { const title = titleMatch[1]?.trim() || 'Unknown Event'; const link = linkMatch[1]?.trim() || ''; const description = descMatch ? descMatch[1]?.replace(/<[^>]*>/g, '').trim() : ''; const date = dateMatch ? dateMatch[1]?.trim() : ''; const creator = creatorMatch ? creatorMatch[1]?.trim() : ''; events.push({ id: link || `event-${events.length}`, title, text: `${title}\n\nAuthor: ${creator || 'Unknown'}\nDate: ${date || 'Unknown'}\n\n${description || 'No description available'}`, url: link, metadata: { date, author: creator, description, }, }); } } } result = { results: events, totalEvents: events.length, daysBack: days, timelineUrl: 'https://core.trac.wordpress.org/timeline', }; } catch (error) { result = { results: [], error: error instanceof Error ? error.message : 'Unknown error', daysBack: days, timelineUrl: 'https://core.trac.wordpress.org/timeline', }; } break; } case "getTracInfo": { const { type } = args; try { let data: any = {}; let uniqueValues = new Set<string>(); // Get a sample of tickets to extract unique values for the requested field const queryUrl = new URL('https://core.trac.wordpress.org/query'); queryUrl.searchParams.set('format', 'csv'); queryUrl.searchParams.set('max', '1000'); // Get more tickets for better coverage const response = await fetch(queryUrl.toString(), { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WordPress-Trac-MCP-Server/1.0)', 'Accept': 'text/csv,text/plain,*/*', 'Accept-Language': 'en-US,en;q=0.9', } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const csvData = await response.text(); // Parse CSV data const lines = csvData.replace(/^\uFEFF/, '').trim().split('\n'); const headers = (lines[0] || '').split(',').map(h => h.replace(/"/g, '').trim()); // Find the column index for the requested type let columnIndex = -1; switch (type) { case "components": // Components are not in the default query, need different approach throw new Error(`Components list not available in default query. Try using the search function instead.`); case "milestones": columnIndex = headers.indexOf('Milestone'); break; case "priorities": columnIndex = headers.indexOf('Priority'); break; case "severities": // Severities are not in the default query throw new Error(`Severities list not available in default query. Try using the search function instead.`); case "types": columnIndex = headers.indexOf('Type'); break; case "statuses": columnIndex = headers.indexOf('Status'); break; default: throw new Error(`Unknown info type: ${type}. Available types: milestones, priorities, types, statuses`); } if (columnIndex === -1) { throw new Error(`Column not found for type: ${type}. Available columns: ${headers.join(', ')}`); } // Extract unique values using better CSV parsing for (let i = 1; i < lines.length && lines[i]; i++) { const line = lines[i]?.trim(); if (!line) continue; // Better CSV parsing - handle quoted fields properly const values = []; let currentField = ''; let inQuotes = false; let escapeNext = false; for (let j = 0; j < line.length; j++) { const char = line[j]; if (escapeNext) { currentField += char; escapeNext = false; continue; } if (char === '\\') { escapeNext = true; continue; } if (char === '"') { if (inQuotes) { // Check if this is an escaped quote if (j + 1 < line.length && line[j + 1] === '"') { currentField += '"'; j++; // Skip the next quote } else { inQuotes = false; } } else { inQuotes = true; } } else if (char === ',' && !inQuotes) { values.push(currentField.trim()); currentField = ''; } else { currentField += char; } } values.push(currentField.trim()); if (values[columnIndex]?.trim()) { uniqueValues.add(values[columnIndex]?.trim() || ''); } } data = Array.from(uniqueValues).sort(); result = { id: type, title: `WordPress Trac ${type}`, text: `${type.charAt(0).toUpperCase() + type.slice(1)} available in WordPress Trac:\n\n${data.join('\n')}`, url: 'https://core.trac.wordpress.org/', metadata: { type, data, total: data.length, }, }; } catch (error) { result = { id: type, title: `Error loading ${type}`, text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}. Available types: components, milestones, priorities, severities, types, statuses`, url: 'https://core.trac.wordpress.org/', metadata: { error: true }, }; } break; } default: throw new Error(`Unknown tool: ${name}`); } return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }, }; } catch (error) { return { jsonrpc: "2.0", id, error: { code: -32603, message: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, }, }; } default: return { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}`, }, }; } } /** * Handle ChatGPT-specific MCP JSON-RPC 2.0 requests * Follows OpenAI's requirements for Deep Research: simplified tools (search, fetch) and OpenAI response format */ async function handleChatGPTMcpRequest(request: any): Promise<any> { const { method, params, id } = request; switch (method) { case "initialize": return { jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {}, }, serverInfo: { name: "WordPress Trac", version: "1.0.0", }, }, }; case "tools/list": return { jsonrpc: "2.0", id, result: { tools: [ { name: "search", description: `Search WordPress Trac for tickets, changesets, and timeline activity. Query Types: - Ticket searches: Use keywords like "block editor", "media upload", "REST API" to find related tickets - Specific tickets: Use ticket numbers like "#61234" or "61234" to find specific tickets - Changesets: Use revision numbers like "r58504" or "58504" to find code changes - Recent activity: Use terms like "recent", "timeline", "latest" to see recent Trac activity - Components: Search by component like "REST API", "Block Editor", "Media" to find tickets in that area`, inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query for WordPress Trac. Can be keywords, ticket numbers, revision numbers, or component names.", } }, required: ["query"] } }, { name: "fetch", description: "Retrieve detailed information about a specific WordPress Trac item by its ID.", inputSchema: { type: "object", properties: { id: { type: "string", description: "The ID of the item to fetch detailed information for (e.g., '61234' for ticket, 'r58504' for changeset).", } }, required: ["id"] } } ], }, }; case "tools/call": const { name, arguments: args } = params; try { let result: any; switch (name) { case "search": { const { query } = args; try { // Intelligent query routing based on patterns const isTicketNumber = /^#?(\d+)$/.test(query.trim()); const isRevisionNumber = /^r?(\d+)$/.test(query.trim()) && query.includes('r'); const isTimelineQuery = /\b(recent|timeline|latest|activity)\b/i.test(query); let searchResults: any[] = []; if (isTicketNumber) { // Direct ticket lookup const ticketId = parseInt(query.replace('#', ''), 10); const ticketResult = await getTicketForChatGPT(ticketId, false); if (ticketResult && !ticketResult.metadata?.error) { searchResults = [ticketResult]; } } else if (isRevisionNumber) { // Direct changeset lookup const revision = parseInt(query.replace('r', ''), 10); const changesetResult = await getChangesetForChatGPT(revision, false); if (changesetResult && !changesetResult.metadata?.error) { searchResults = [changesetResult]; } } else if (isTimelineQuery) { // Timeline search const timelineResults = await getTimelineForChatGPT(7, 20); searchResults = timelineResults.results || []; } else { // Keyword search for tickets const ticketResults = await searchTicketsForChatGPT(query, 10); searchResults = ticketResults.results || []; // Also populate cache for potential fetch operations for (const ticketResult of searchResults) { if (ticketResult.id && ticketResult.metadata?.ticket) { chatgptCache.set(ticketResult.id, ticketResult.metadata.ticket); } } } result = { results: searchResults, query, totalFound: searchResults.length, }; } catch (error) { result = { results: [], query, error: error instanceof Error ? error.message : 'Unknown error', }; } break; } case "fetch": { const { id } = args; try { // Determine if it's a ticket, changeset, or cached item const isRevision = /^r?\d+$/.test(id) && id.includes('r'); const isTicketId = /^\d+$/.test(id); let fetchResult: any; if (chatgptCache.has(id)) { // Use cached data if available const cached = chatgptCache.get(id); fetchResult = await formatCachedTicketForChatGPT(cached, id); } else if (isRevision) { // Fetch changeset details const revision = parseInt(id.replace('r', ''), 10); fetchResult = await getChangesetForChatGPT(revision, true); } else if (isTicketId) { // Fetch ticket details const ticketId = parseInt(id, 10); fetchResult = await getTicketForChatGPT(ticketId, true); } else { throw new Error(`Invalid ID format: ${id}`); } result = fetchResult; } catch (error) { result = { id: id, title: `Error loading ${id}`, text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, url: `https://core.trac.wordpress.org/`, metadata: { error: true }, }; } break; } default: throw new Error(`Unknown tool: ${name}`); } return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], }, }; } catch (error) { return { jsonrpc: "2.0", id, error: { code: -32603, message: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, }, }; } default: return { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}`, }, }; } } // Helper functions for ChatGPT response formatting async function searchTicketsForChatGPT(query: string, limit: number) { // Reuse existing searchTickets logic but format for ChatGPT try { const queryUrl = new URL('https://core.trac.wordpress.org/query'); queryUrl.searchParams.set('format', 'csv'); queryUrl.searchParams.set('max', Math.min(limit, 50).toString()); if (query.includes('=') || query.includes('~')) { queryUrl.searchParams.set('summary', query); } else { queryUrl.searchParams.set('summary', `~${query}`); } const response = await fetch(queryUrl.toString(), { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WordPress-Trac-MCP-Server/1.0)', 'Accept': 'text/csv,text/plain,*/*', 'Accept-Language': 'en-US,en;q=0.9', } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const csvData = await response.text(); const lines = csvData.trim().split('\n'); if (lines.length < 2) { return { results: [] }; } const results = []; for (let i = 1; i < lines.length; i++) { const line = lines[i]?.trim(); if (!line) continue; const values = parseCSVLine(line); if (values.length >= 2 && values[0] && !isNaN(parseInt(values[0]))) { const ticketId = parseInt(values[0]); const title = values[1] || ''; const status = values[4] || 'unknown'; const owner = values[2] || 'unassigned'; const type = values[3] || 'unknown'; const priority = values[5] || 'unknown'; const milestone = values[6] || 'none'; const ticket = { id: ticketId, title, status, owner, type, priority, milestone, }; // Cache for potential fetch chatgptCache.set(ticketId.toString(), ticket); results.push({ id: ticketId.toString(), title: `#${ticketId}: ${title}`, text: `Ticket #${ticketId}: ${title}\nStatus: ${status}\nType: ${type}\nPriority: ${priority}\nOwner: ${owner}\nMilestone: ${milestone}`, url: `https://core.trac.wordpress.org/ticket/${ticketId}`, metadata: { ticket }, }); } } return { results }; } catch (error) { return { results: [], error: error instanceof Error ? error.message : 'Unknown error' }; } } async function getTicketForChatGPT(ticketId: number, includeComments: boolean) { try { const searchUrl = new URL('https://core.trac.wordpress.org/query'); searchUrl.searchParams.set('format', 'csv'); searchUrl.searchParams.set('id', ticketId.toString()); searchUrl.searchParams.set('max', '1'); const response = await fetch(searchUrl.toString(), { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WordPress-Trac-MCP-Server/1.0)', 'Accept': 'text/csv,text/plain,*/*', } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const csvData = await response.text(); const lines = csvData.replace(/^\uFEFF/, '').trim().split(/\r?\n/); if (lines.length < 2) { throw new Error(`Ticket ${ticketId} not found`); } const values = parseCSVLine(lines[1] || ''); if (values.length >= 2 && values[0] && parseInt(values[0]) === ticketId) { const ticket = { id: parseInt(values[0]), summary: values[1] || '', owner: values[2] || '', type: values[3] || '', status: values[4] || '', priority: values[5] || '', milestone: values[6] || '', }; // Cache the ticket chatgptCache.set(ticketId.toString(), ticket); const commentNote = includeComments ? "\n\nNote: Full comments and description available on the ticket page." : ""; return { id: ticketId.toString(), title: `#${ticketId}: ${ticket.summary}`, text: `Ticket #${ticketId}: ${ticket.summary}\n\nStatus: ${ticket.status}\nType: ${ticket.type}\nPriority: ${ticket.priority}\nOwner: ${ticket.owner}\nMilestone: ${ticket.milestone}${commentNote}`, url: `https://core.trac.wordpress.org/ticket/${ticketId}`, metadata: { ticket }, }; } else { throw new Error(`Ticket ${ticketId} not found`); } } catch (error) { return { id: ticketId.toString(), title: `Error loading ticket ${ticketId}`, text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, url: `https://core.trac.wordpress.org/ticket/${ticketId}`, metadata: { error: true }, }; } } async function getChangesetForChatGPT(revision: number, includeDiff: boolean) { try { const changesetUrl = `https://core.trac.wordpress.org/changeset/${revision}`; const response = await fetch(changesetUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } }); if (!response.ok) { throw new Error(`Changeset ${revision} not found`); } const html = await response.text(); const messageMatch = html.match(/<dd class="message[^"]*"[^>]*>\s*<p[^>]*>(.*?)<\/p>/s) || html.match(/<dd class="message[^"]*"[^>]*>(.*?)<\/dd>/s); const authorMatch = html.match(/<dd class="author"[^>]*><span class="trac-author"[^>]*>(.*?)<\/span><\/dd>/s) || html.match(/<dt class="property author">Author:<\/dt>\s*<dd class="author">(.*?)<\/dd>/s); const dateMatch = html.match(/<dd class="date"[^>]*>(.*?)<\/dd>/s) || html.match(/<dt class="property date">Date:<\/dt>\s*<dd class="date">(.*?)<\/dd>/s); const changeset = { revision, author: authorMatch?.[1] ? authorMatch[1].replace(/<[^>]*>/g, '').trim() : '', date: dateMatch?.[1] ? dateMatch[1].replace(/<[^>]*>/g, '').trim() : '', message: messageMatch?.[1] ? messageMatch[1].replace(/<[^>]*>/g, '').trim() : '', diff: '', }; // Get diff if requested if (includeDiff) { try { const diffUrl = `${changesetUrl}?format=diff`; const diffResponse = await fetch(diffUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } }); if (diffResponse.ok) { let diffText = await diffResponse.text(); const maxDiffLength = 2000; if (diffText.length > maxDiffLength) { diffText = diffText.substring(0, maxDiffLength) + '\n... [diff truncated] ...'; } changeset.diff = diffText; } } catch (error) { console.warn('Failed to load diff:', error); } } // Cache the changeset chatgptCache.set(`r${revision}`, changeset); const diffText = changeset.diff ? `\n\nDiff:\n${changeset.diff}` : ''; return { id: `r${revision}`, title: `r${revision}: ${changeset.message}`, text: `Changeset r${revision}\nAuthor: ${changeset.author}\nDate: ${changeset.date}\n\nMessage:\n${changeset.message}${diffText}`, url: changesetUrl, metadata: { changeset }, }; } catch (error) { return { id: `r${revision}`, title: `Error loading changeset ${revision}`, text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, url: `https://core.trac.wordpress.org/changeset/${revision}`, metadata: { error: true }, }; } } async function getTimelineForChatGPT(days: number, limit: number) { try { const timelineUrl = `https://core.trac.wordpress.org/timeline?from=${days}%2Bdays+ago&max=${Math.min(limit, 100)}&format=rss`; const response = await fetch(timelineUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } }); if (!response.ok) { throw new Error(`Failed to fetch timeline: ${response.statusText}`); } const rssText = await response.text(); const itemMatches = rssText.match(/<item>([\s\S]*?)<\/item>/g); const results = []; if (itemMatches) { for (const itemMatch of itemMatches) { let titleMatch = itemMatch.match(/<title><!\[CDATA\[(.*?)\]\]><\/title>/s); let linkMatch = itemMatch.match(/<link>(.*?)<\/link>/s); let descMatch = itemMatch.match(/<description><!\[CDATA\[(.*?)\]\]><\/description>/s); let dateMatch = itemMatch.match(/<pubDate>(.*?)<\/pubDate>/s); let creatorMatch = itemMatch.match(/<dc:creator>(.*?)<\/dc:creator>/s); if (!titleMatch) titleMatch = itemMatch.match(/<title>(.*?)<\/title>/s); if (!descMatch) descMatch = itemMatch.match(/<description>(.*?)<\/description>/s); if (titleMatch && linkMatch) { const title = titleMatch[1]?.trim() || 'Unknown Event'; const link = linkMatch[1]?.trim() || ''; const description = descMatch ? descMatch[1]?.replace(/<[^>]*>/g, '').trim() : ''; const date = dateMatch ? dateMatch[1]?.trim() : ''; const creator = creatorMatch ? creatorMatch[1]?.trim() : ''; results.push({ id: link || `event-${results.length}`, title, text: `${title}\n\nAuthor: ${creator || 'Unknown'}\nDate: ${date || 'Unknown'}\n\n${description || 'No description available'}`, url: link, metadata: { date, author: creator, description }, }); } } } return { results }; } catch (error) { return { results: [], error: error instanceof Error ? error.message : 'Unknown error' }; } } async function formatCachedTicketForChatGPT(cached: any, id: string) { return { id, title: `#${cached.id || id}: ${cached.summary || cached.title || 'Unknown'}`, text: `Ticket #${cached.id || id}: ${cached.summary || cached.title || 'Unknown'}\n\nStatus: ${cached.status || 'unknown'}\nType: ${cached.type || 'unknown'}\nPriority: ${cached.priority || 'unknown'}\nOwner: ${cached.owner || 'unassigned'}\nMilestone: ${cached.milestone || 'none'}\n\nNote: This is cached data. Visit the ticket URL for complete details.`, url: `https://core.trac.wordpress.org/ticket/${cached.id || id}`, metadata: { ticket: cached, cached: true }, }; } // Simple CSV parser helper function parseCSVLine(line: string): string[] { const values = []; let currentField = ''; let inQuotes = false; let escapeNext = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (escapeNext) { currentField += char; escapeNext = false; continue; } if (char === '\\') { escapeNext = true; continue; } if (char === '"') { if (inQuotes) { if (i + 1 < line.length && line[i + 1] === '"') { currentField += '"'; i++; } else { inQuotes = false; } } else { inQuotes = true; } } else if (char === ',' && !inQuotes) { values.push(currentField.trim()); currentField = ''; } else { currentField += char; } } values.push(currentField.trim()); return values; } // WordPress.com styled landing page function getLandingPage(url: URL, versionInfo?: { id: string; tag?: string; timestamp: string }) { return ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>WordPress Trac MCP Server</title> <link rel="icon" type="image/x-icon" href="/favicon.ico"> <!-- Open Graph / Facebook --> <meta property="og:type" content="website"> <meta property="og:url" content="${url.origin}"> <meta property="og:title" content="WordPress Trac MCP Server"> <meta property="og:description" content="Model Context Protocol server for WordPress.org Trac integration"> <meta property="og:image" content="${url.origin}/og-image.png"> <!-- Twitter --> <meta property="twitter:card" content="summary_large_image"> <meta property="twitter:url" content="${url.origin}"> <meta property="twitter:title" content="WordPress Trac MCP Server"> <meta property="twitter:description" content="Model Context Protocol server for WordPress.org Trac integration"> <meta property="twitter:image" content="${url.origin}/og-image.png"> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=EB+Garamond:wght@400;500&display=swap" rel="stylesheet"> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #1a1a1a; background: #ffffff; max-width: 600px; margin: 0 auto; padding: 40px 20px; } h1 { font-family: 'EB Garamond', serif; font-weight: 500; font-size: 2.25rem; color: #1a1a1a; margin-bottom: 0.5rem; } h2 { font-family: 'EB Garamond', serif; font-weight: 500; font-size: 1.5rem; color: #1a1a1a; margin: 2rem 0 1rem 0; } h3 { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif; font-weight: 600; font-size: 1.1rem; color: #1a1a1a; margin: 1.5rem 0 0.75rem 0; } p { margin-bottom: 1rem; color: #4a4a4a; } .subtitle { color: #666; margin-bottom: 2rem; } code { background: #f6f8fa; padding: 2px 6px; border-radius: 3px; font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; font-size: 0.9em; color: #3f57e1; } .code-block { background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 1rem; margin: 1rem 0; overflow-x: auto; } .code-block code { background: none; padding: 0; color: #24292e; } .mcp-tool { margin-bottom: 0.75rem; } .mcp-tool code { font-weight: 600; } a { color: #3f57e1; text-decoration: none; } a:hover { text-decoration: underline; } .footer { margin-top: 3rem; padding-top: 2rem; border-top: 1px solid #e1e4e8; text-align: center; color: #666; font-size: 0.9rem; } </style> </head> <body> <h1>WordPress Trac MCP Server</h1> <p class="subtitle">Model Context Protocol server for WordPress.org Trac integration</p> <h2>Standard MCP Tools</h2> <div class="mcp-tool"> <code>searchTickets</code> - Search for WordPress Trac tickets by keyword or filter </div> <div class="mcp-tool"> <code>getTicket</code> - Get detailed information about a specific ticket </div> <div class="mcp-tool"> <code>getChangeset</code> - Get information about a code changeset/commit </div> <div class="mcp-tool"> <code>getTimeline</code> - Get recent activity from WordPress Trac </div> <div class="mcp-tool"> <code>getTracInfo</code> - Get Trac metadata (components, milestones, priorities, severities) </div> <h2>ChatGPT Deep Research Tools</h2> <div class="mcp-tool"> <code>search</code> - Intelligent search for tickets, changesets, and activity </div> <div class="mcp-tool"> <code>fetch</code> - Get detailed information about specific items </div> <h2>Configuration</h2> <h3>Standard MCP (Claude Desktop, etc.)</h3> <div class="code-block"> <code>{ "mcpServers": { "wordpress-trac": { "command": "npx", "args": ["mcp-remote", "${url.origin}/mcp"] } } }</code> </div> <h3>ChatGPT Deep Research</h3> <p>ChatGPT uses a different connection method:</p> <div class="code-block"> <code>1. Open ChatGPT Settings → Connectors tab 2. Add Server → Import remote MCP server: ${url.origin}/mcp/chatgpt 3. Enable in Composer → Deep Research tool 4. Add as research source if needed</code> </div> <p>See: <a href="https://platform.openai.com/docs/mcp#connect-in-chatgpt">ChatGPT MCP Documentation</a></p> <div class="footer"> <p><a href="https://core.trac.wordpress.org/">WordPress Trac</a> • <a href="https://modelcontextprotocol.io/">MCP Docs</a> • an experiment by <a href="https://automattic.ai">A8C AI</a></p> ${versionInfo ? `<p style="margin-top: 0.5rem; font-size: 0.8rem; color: #999;"> Version: <code style="font-size: 0.8rem;">${versionInfo.id.substring(0, 8)}</code> ${versionInfo.tag ? ` • Tag: <code style="font-size: 0.8rem;">${versionInfo.tag}</code>` : ''} • Deployed: ${new Date(versionInfo.timestamp).toLocaleString()} </p>` : ''} </div> </body> </html> `; } // Environment interface interface Env { ENVIRONMENT?: string; CF_VERSION_METADATA?: { id: string; tag?: string; timestamp: string; }; } // Cloudflare Worker export export default { async fetch(request: Request, env: Env, _ctx: ExecutionContext): Promise<Response> { const url = new URL(request.url); // Serve favicon if (url.pathname === "/favicon.ico") { // WordPress-style "W" favicon as base64 const faviconBase64 = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAA7AAAAOwBeShxvQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAKPSURBVFiFtZe/axRBFMc/s3t7d3kTc4kJRpRYiIiNjYWNhYWFhY2FQkD8AxRsbCwsLCxsLGwsLCwsLCwsLCwsLCwUBEEQBEEQBEEQRBAiRo3Jmcvd7u6MxezO7d7tXS7qg2GZN+/H973vvZlZoUBEROYlWJRgXoKzwDRQKXJTGgYBNAQ8lOCGgG0iogqz4DtJsCLBvAT7iqTPCo4I0JGgKkExJR7PB7kpQVfAtd9lnyYjAVYFuCZg+n8wT8N+CVoClPtCQIT5lEwlLl6XNbqxNnVZY0LWeKiPMq9RLzKvFOCOBDsKid/VR3iv26RvJ/p1m3v6KLUirFOQSccdqsF1BYoJRIS5DNGEzLChQ8oqJCXiqjDGrjjyYb3HNQqSNQqSFfIyiJFE31bD+NJyOanHuF8LaBQkLgJ4AlbTzCM8K6zQVyF9FQIgoLTdQLo2nfCEbjhLJbiJjBgAdBRoJJmH9qJGQfJC7+NROkL0iXArJhD7aKsqHQklFWKpEICKbeKYfiZRRIJJMxpEKkrSlcAGKgLMJMlExOhLEq6AqQLi88rjlXkzfmQAbQWRfJdWHscMdGSErELGCohXBNC2TNysGODRNa22DRKYMkKgglGRPg9VBxEBvCjAGUdAxzJxlIuZJqKIBD0VENEHICoKD4DjJjAPBKxHNYKdQkcRtYzL7i1NCvyNOUQ5XgKcBLoiMCJ1BdZ9uJzXagtFEAMD9INP3I/o+RM8CPWvQAOY62e7RsEOEfmzP8BB4DxwFJg1x9uJtdOLN2AzZ7wtosOjDcO2rwEFGoAIiJI6LNYPZZw7oqBvAD6aG4wCBp9t4xdOBu6YRquJsQAAAABJRU5ErkJggg=="; const faviconBuffer = Uint8Array.from(atob(faviconBase64), c => c.charCodeAt(0)); return new Response(faviconBuffer, { headers: { "Content-Type": "image/x-icon", "Cache-Control": "public, max-age=31536000" } }); } // Generate OG image if (url.pathname === "/og-image.png") { const title = (url.searchParams.get("title") || "WordPress Trac MCP Server") .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;'); const subtitle = (url.searchParams.get("subtitle") || "Model Context Protocol server for WordPress.org Trac integration") .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;'); // Create a WordPress-branded OG image const svg = ` <svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg"> <defs> <style> <![CDATA[ @import url('https://fonts.googleapis.com/css2?family=EB+Garamond:wght@400;500;700&display=swap'); .title { font-family: 'EB Garamond', serif; font-size: 48px; font-weight: 700; fill: white; } .subtitle { font-family: 'EB Garamond', serif; font-size: 24px; font-weight: 400; fill: rgba(255,255,255,0.9); } ]]> </style> </defs> <!-- WordPress Blue Background --> <rect width="1200" height="630" fill="#21759b"/> <!-- WordPress Icon in Upper Left --> <g transform="translate(60, 60) scale(0.8)"> <g fill="white"> <path d="m8.708 61.26c0 20.802 12.089 38.779 29.619 47.298l-25.069-68.686c-2.916 6.536-4.55 13.769-4.55 21.388z"/> <path d="m96.74 58.608c0-6.495-2.333-10.993-4.334-14.494-2.664-4.329-5.161-7.995-5.161-12.324 0-4.831 3.664-9.328 8.825-9.328.233 0 .454.029.681.042-9.35-8.566-21.807-13.796-35.489-13.796-18.36 0-34.513 9.42-43.91 23.688 1.233.037 2.395.063 3.382.063 5.497 0 14.006-.667 14.006-.667 2.833-.167 3.167 3.994.337 4.329 0 0-2.847.335-6.015.501l19.138 56.925 11.501-34.493-8.188-22.434c-2.83-.166-5.511-.501-5.511-.501-2.832-.166-2.5-4.496.332-4.329 0 0 8.679.667 13.843.667 5.496 0 14.006-.667 14.006-.667 2.835-.167 3.168 3.994.337 4.329 0 0-2.853.335-6.015.501l18.992 56.494 5.242-17.517c2.272-7.269 4.001-12.49 4.001-16.989z"/> <path d="m62.184 65.857-15.768 45.819c4.708 1.384 9.687 2.141 14.846 2.141 6.12 0 11.989-1.058 17.452-2.979-.141-.225-.269-.464-.374-.724z"/> <path d="m107.376 36.046c.226 1.674.354 3.471.354 5.404 0 5.333-.996 11.328-3.996 18.824l-16.053 46.413c15.624-9.111 26.133-26.038 26.133-45.426.001-9.137-2.333-17.729-6.438-25.215z"/> <path d="m61.262 0c-33.779 0-61.262 27.481-61.262 61.26 0 33.783 27.483 61.263 61.262 61.263 33.778 0 61.265-27.48 61.265-61.263-.001-33.779-27.487-61.26-61.265-61.26zm0 119.715c-32.23 0-58.453-26.223-58.453-58.455 0-32.23 26.222-58.451 58.453-58.451 32.229 0 58.45 26.221 58.45 58.451 0 32.232-26.221 58.455-58.45 58.455z"/> </g> </g> <!-- Title in Bottom Left --> <text x="60" y="520" class="title">${title}</text> <!-- Subtitle in Bottom Left --> <text x="60" y="560" class="subtitle">${subtitle}</text> </svg> `; return new Response(svg, { headers: { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=31536000" } }); } // Serve landing page at root if (url.pathname === "/") { const versionInfo = env.CF_VERSION_METADATA; return new Response(getLandingPage(url, versionInfo), { headers: { "Content-Type": "text/html" } }); } // Health check if (url.pathname === "/health") { return new Response("OK", { status: 200 }); } // Handle MCP endpoints if (url.pathname === "/mcp" || url.pathname === "/mcp/chatgpt") { if (request.method !== "POST") { return new Response("Method not allowed", { status: 405 }); } try { const body = await request.json(); const mcpRequest = JsonRpcRequestSchema.parse(body); // Route to appropriate handler based on endpoint const response = url.pathname === "/mcp/chatgpt" ? await handleChatGPTMcpRequest(mcpRequest) : await handleMcpRequest(mcpRequest); return new Response(JSON.stringify(response), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", } }); } catch (error) { return new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32700, message: "Parse error", }, }), { status: 400, headers: { "Content-Type": "application/json" } }); } } // Handle CORS preflight if (request.method === "OPTIONS") { return new Response(null, { headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", } }); } return new Response("Not found", { status: 404 }); }, };

Implementation Reference

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/Jameswlepage/trac-mcp'

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