name: Check Glama Link on: pull_request_target: types: [opened, edited, synchronize, closed] permissions: contents: read pull-requests: write issues: write jobs: # Post-merge welcome comment welcome: if: github.event.action == 'closed' && github.event.pull_request.merged == true runs-on: ubuntu-latest steps: - name: Post welcome comment uses: actions/github-script@v7 with: script: | const { owner, repo } = context.repo; const pr_number = context.payload.pull_request.number; const marker = ''; const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: pr_number, per_page: 100, }); if (!comments.some(c => c.body.includes(marker))) { await github.rest.issues.createComment({ owner, repo, issue_number: pr_number, body: `${marker}\nThank you for your contribution! Your server has been merged. Are you in the MCP [Discord](https://glama.ai/mcp/discord)? Let me know your Discord username and I will give you a **server-author** flair. If you also have a remote server, you can list it under https://glama.ai/mcp/connectors` }); } # Validation checks (only on open PRs) check-submission: if: github.event.action != 'closed' runs-on: ubuntu-latest steps: - name: Checkout base branch uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.base.ref }} - name: Validate PR submission uses: actions/github-script@v7 with: script: | const fs = require('fs'); const { owner, repo } = context.repo; const pr_number = context.payload.pull_request.number; // Read existing README to check for duplicates const readme = fs.readFileSync('README.md', 'utf8'); const existingUrls = new Set(); const urlRegex = /\(https:\/\/github\.com\/[^)]+\)/gi; for (const match of readme.matchAll(urlRegex)) { existingUrls.add(match[0].toLowerCase()); } // Get the PR diff const { data: files } = await github.rest.pulls.listFiles({ owner, repo, pull_number: pr_number, per_page: 100, }); // Permitted emojis const permittedEmojis = [ '\u{1F396}\uFE0F', // 🎖️ official '\u{1F40D}', // 🐍 Python '\u{1F4C7}', // 📇 TypeScript/JS '\u{1F3CE}\uFE0F', // 🏎️ Go '\u{1F980}', // 🦀 Rust '#\uFE0F\u20E3', // #️⃣ C# '\u2615', // ☕ Java '\u{1F30A}', // 🌊 C/C++ '\u{1F48E}', // 💎 Ruby '\u2601\uFE0F', // ☁️ Cloud '\u{1F3E0}', // 🏠 Local '\u{1F4DF}', // 📟 Embedded '\u{1F34E}', // 🍎 macOS '\u{1FA9F}', // 🪟 Windows '\u{1F427}', // 🐧 Linux ]; // Only check added lines (starting with +) for glama link const hasGlama = files.some(file => file.patch && file.patch.split('\n') .filter(line => line.startsWith('+')) .some(line => line.includes('glama.ai/mcp/servers')) ); // Check added lines for emoji usage const addedLines = files .filter(f => f.patch) .flatMap(f => f.patch.split('\n').filter(line => line.startsWith('+'))) .filter(line => line.includes('](https://github.com/')); let hasValidEmoji = false; let hasInvalidEmoji = false; const invalidLines = []; const badNameLines = []; const duplicateUrls = []; const nonGithubUrls = []; // Check for non-GitHub URLs in added entry lines (list items with markdown links) const allAddedEntryLines = files .filter(f => f.patch) .flatMap(f => f.patch.split('\n').filter(line => line.startsWith('+'))) .map(line => line.replace(/^\+/, '')) .filter(line => /^\s*-\s*\[/.test(line)); for (const line of allAddedEntryLines) { // Extract the primary link URL (first markdown link) const linkMatch = line.match(/\]\((https?:\/\/[^)]+)\)/); if (linkMatch) { const url = linkMatch[1]; if (!url.startsWith('https://github.com/')) { nonGithubUrls.push(url); } } } // Check for duplicates for (const line of addedLines) { const ghMatch = line.match(/\(https:\/\/github\.com\/[^)]+\)/i); if (ghMatch && existingUrls.has(ghMatch[0].toLowerCase())) { duplicateUrls.push(ghMatch[0].replace(/[()]/g, '')); } } for (const line of addedLines) { const usedPermitted = permittedEmojis.filter(e => line.includes(e)); if (usedPermitted.length > 0) { hasValidEmoji = true; } else { // Line with a GitHub link but no permitted emoji invalidLines.push(line.replace(/^\+/, '').trim()); } // Check for emojis that aren't in the permitted list const emojiRegex = /\p{Emoji_Presentation}|\p{Emoji}\uFE0F/gu; const allEmojis = line.match(emojiRegex) || []; const unknownEmojis = allEmojis.filter(e => !permittedEmojis.some(p => p.includes(e) || e.includes(p))); if (unknownEmojis.length > 0) { hasInvalidEmoji = true; } // Check that the link text uses full owner/repo format // Pattern: [link-text](https://github.com/owner/repo) const entryRegex = /\[([^\]]+)\]\(https:\/\/github\.com\/([^/]+)\/([^/)]+)\)/; const match = line.match(entryRegex); if (match) { const linkText = match[1]; const expectedName = `${match[2]}/${match[3]}`; // Link text should contain owner/repo (case-insensitive) if (!linkText.toLowerCase().includes('/')) { badNameLines.push({ linkText, expectedName }); } } } const emojiOk = addedLines.length === 0 || (hasValidEmoji && !hasInvalidEmoji && invalidLines.length === 0); const nameOk = badNameLines.length === 0; const noDuplicates = duplicateUrls.length === 0; const allGithub = nonGithubUrls.length === 0; // Apply glama labels const glamaLabel = hasGlama ? 'has-glama' : 'missing-glama'; const glamaLabelRemove = hasGlama ? 'missing-glama' : 'has-glama'; // Apply emoji labels const emojiLabel = emojiOk ? 'has-emoji' : 'missing-emoji'; const emojiLabelRemove = emojiOk ? 'missing-emoji' : 'has-emoji'; // Apply name labels const nameLabel = nameOk ? 'valid-name' : 'invalid-name'; const nameLabelRemove = nameOk ? 'invalid-name' : 'valid-name'; const labelsToAdd = [glamaLabel, emojiLabel, nameLabel]; const labelsToRemove = [glamaLabelRemove, emojiLabelRemove, nameLabelRemove]; if (!noDuplicates) { labelsToAdd.push('duplicate'); } else { labelsToRemove.push('duplicate'); } if (!allGithub) { labelsToAdd.push('non-github-url'); } else { labelsToRemove.push('non-github-url'); } await github.rest.issues.addLabels({ owner, repo, issue_number: pr_number, labels: labelsToAdd, }); for (const label of labelsToRemove) { try { await github.rest.issues.removeLabel({ owner, repo, issue_number: pr_number, name: label, }); } catch (e) { // Label wasn't present, ignore } } // Post comments for issues, avoiding duplicates const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number: pr_number, per_page: 100, }); // Glama comment if (!hasGlama) { const marker = ''; if (!comments.some(c => c.body.includes(marker))) { await github.rest.issues.createComment({ owner, repo, issue_number: pr_number, body: `${marker}\nHey, To ensure that only working servers are listed, we're updating our listing requirements. Please complete the following steps: 1. **Ensure your server is listed on Glama.** If it isn't already, submit it at https://glama.ai/mcp/servers and verify that it passes all checks (including a successfully built Docker image and a release). 2. **Update your PR** by adding a \`[glama](https://glama.ai/mcp/servers/...)\` link immediately after the GitHub repository link, pointing to your server's Glama listing page. If you need any assistance, feel free to ask questions here or on [Discord](https://glama.ai/discord). P.S. If your server already has a hosted endpoint, you can also list it under https://glama.ai/mcp/connectors.` }); } } // Emoji comment if (!emojiOk && addedLines.length > 0) { const marker = ''; if (!comments.some(c => c.body.includes(marker))) { const emojiList = [ '🎖️ – official implementation', '🐍 – Python', '📇 – TypeScript / JavaScript', '🏎️ – Go', '🦀 – Rust', '#️⃣ – C#', '☕ – Java', '🌊 – C/C++', '💎 – Ruby', '☁️ – Cloud Service', '🏠 – Local Service', '📟 – Embedded Systems', '🍎 – macOS', '🪟 – Windows', '🐧 – Linux', ].map(e => `- ${e}`).join('\n'); await github.rest.issues.createComment({ owner, repo, issue_number: pr_number, body: `${marker}\nYour submission is missing a required emoji tag or uses an unrecognized one. Each entry must include at least one of the permitted emojis after the repository link. **Permitted emojis:** ${emojiList} Please update your PR to include the appropriate emoji(s). See existing entries for examples.` }); } } // Duplicate comment if (!noDuplicates) { const marker = ''; if (!comments.some(c => c.body.includes(marker))) { const dupes = duplicateUrls.map(u => `- ${u}`).join('\n'); await github.rest.issues.createComment({ owner, repo, issue_number: pr_number, body: `${marker}\nThe following server(s) are already listed in the repository: ${dupes} Please remove the duplicate entries from your PR.` }); } } // Non-GitHub URL comment if (!allGithub) { const marker = ''; if (!comments.some(c => c.body.includes(marker))) { const urls = nonGithubUrls.map(u => `- ${u}`).join('\n'); await github.rest.issues.createComment({ owner, repo, issue_number: pr_number, body: `${marker}\nWe only accept servers hosted on GitHub. The following URLs are not GitHub links: ${urls} Please update your PR to use a \`https://github.com/...\` repository link.` }); } } // Name format comment if (!nameOk) { const marker = ''; if (!comments.some(c => c.body.includes(marker))) { const examples = badNameLines.map( b => `- \`${b.linkText}\` should be \`${b.expectedName}\`` ).join('\n'); await github.rest.issues.createComment({ owner, repo, issue_number: pr_number, body: `${marker}\nThe entry name must use the full \`owner/repo\` format (not just the repo name). ${examples} For example: \`[user/mcp-server-example](https://github.com/user/mcp-server-example)\` Please update your PR to use the full repository name.` }); } }