From 20942fd1aac1700d4ef323b9094583c120adae7d Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:54:46 +0000 Subject: [PATCH 1/5] feat: add Buildkite built-in integration --- apps/docs/docs.json | 1 + apps/docs/integrations/buildkite.mdx | 32 +++++++++++++++++++ apps/docs/integrations/index.mdx | 1 + .../src/components/settings/Integrations.tsx | 2 ++ .../system/custom/logos/brand-icon.tsx | 2 ++ .../roomote-mcp-server/integration-setup.ts | 8 +++++ .../src/server/mcp-self-setup/catalog.ts | 7 ++++ packages/slack/src/mcp-recommendations.ts | 2 ++ .../types/src/__tests__/mcp-oauth.test.ts | 15 +++++++++ packages/types/src/mcp-oauth.ts | 13 ++++++++ packages/types/src/mcp-service-detection.ts | 14 ++++++++ 11 files changed, 97 insertions(+) create mode 100644 apps/docs/integrations/buildkite.mdx diff --git a/apps/docs/docs.json b/apps/docs/docs.json index f18c464448..acd98076a0 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -154,6 +154,7 @@ "integrations/roomote-mcp", "integrations/asana", "integrations/better-stack", + "integrations/buildkite", "integrations/braintrust", "integrations/elevenlabs", "integrations/exa", diff --git a/apps/docs/integrations/buildkite.mdx b/apps/docs/integrations/buildkite.mdx new file mode 100644 index 0000000000..edd4638b25 --- /dev/null +++ b/apps/docs/integrations/buildkite.mdx @@ -0,0 +1,32 @@ +--- +title: Buildkite +description: Inspect Buildkite pipelines, builds, jobs, and tests from Roomote tasks. +icon: 'https://api.iconify.design/simple-icons:buildkite.svg?color=currentColor' +--- + +Connect Buildkite when tasks need CI pipeline, build, job, log, artifact, test, +cluster, agent, or queue context. + +## How setup works + +A deployment operator connects Buildkite once from **Settings > Integrations** +using OAuth. The built-in integration uses Buildkite's hosted +`/mcp/readonly` endpoint and requests its `read` scope. Buildkite's public OAuth +metadata supports dynamic client registration and PKCE, so self-hosted Roomote +deployments do not need a preconfigured Buildkite OAuth client. + +The organization picker shown during authorization is a convenience, not an +access-control boundary. The connection can see organizations available to the +authorizing account, subject to Buildkite's permissions. + +## Network restrictions + +If a Buildkite organization uses an API IP allowlist, add Buildkite's published +MCP egress addresses to that allowlist. The hosted MCP server calls the +Buildkite API from Buildkite infrastructure. + +## Verify the connection + +Start by listing pipelines or inspecting a recent failed build. This built-in +connection is read-only; use a separately configured custom MCP server if a +different endpoint or token-backed toolset is required. diff --git a/apps/docs/integrations/index.mdx b/apps/docs/integrations/index.mdx index ae26253607..7b0007f17b 100644 --- a/apps/docs/integrations/index.mdx +++ b/apps/docs/integrations/index.mdx @@ -78,6 +78,7 @@ from [Personal Settings](/personal-settings). | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------ | | | Project and task context from Asana | Admin connection once | | | Monitoring and incident context | Admin connection once | +| | Read-only CI pipeline and build context | Admin connection once | | | Prompts, runs, and evaluation context | Enable first, then teammates link accounts | | | Voice narration for feature-demo videos | Admin connection once | | | Keyless web search; optional Agent research | Admin enables; API key optional | diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index bd77e8cfa2..ea4cab12c1 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -109,6 +109,8 @@ const DEEP_LINK_ENABLE_DESCRIPTIONS: Record = { 'Roomote will be able to inspect monitoring, incidents, and telemetry.', braintrust: 'Roomote will be able to inspect prompts, evaluations, and AI run history.', + buildkite: + 'Roomote will be able to inspect Buildkite pipelines, builds, jobs, logs, tests, artifacts, and agents through a read-only connection.', grafana: 'Roomote will be able to inspect dashboards, alert rules, live alert state, annotations, and data sources.', granola: diff --git a/apps/web/src/components/system/custom/logos/brand-icon.tsx b/apps/web/src/components/system/custom/logos/brand-icon.tsx index 5243066afc..1b755acaca 100644 --- a/apps/web/src/components/system/custom/logos/brand-icon.tsx +++ b/apps/web/src/components/system/custom/logos/brand-icon.tsx @@ -4,6 +4,7 @@ import { siAsana, siBetterstack, siBraintrust, + siBuildkite, siElevenlabs, siDependabot, siDatadog, @@ -42,6 +43,7 @@ const SIMPLE_ICONS: Record = { asana: siAsana, betterstack: siBetterstack, braintrust: siBraintrust, + buildkite: siBuildkite, elevenlabs: siElevenlabs, dependabot: siDependabot, datadog: siDatadog, diff --git a/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts b/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts index 5ea3a3bd73..a640880883 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts @@ -53,6 +53,14 @@ Sentry uses the workspace MCP integration: Once connected, tasks can inspect Sentry issue and project context, and scheduled Sentry triage automation uses the same read-only MCP connection. +# Buildkite + +Buildkite uses OAuth: +1. A deployment operator enables Buildkite from Settings > Integrations. +2. That operator connects Buildkite once for the deployment via OAuth. + +Once connected, I can inspect pipelines, builds, jobs, logs, artifacts, tests, clusters, agents, and queues through Buildkite's read-only hosted MCP endpoint. Organizations that restrict API access by IP must allowlist Buildkite's published MCP egress addresses. Organization selection during OAuth is a convenience, not an access-control boundary. + # Pylon Pylon uses OAuth: diff --git a/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts b/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts index 6b47f76431..ebb2ea41d4 100644 --- a/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts +++ b/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts @@ -38,6 +38,13 @@ export const MCP_SETUP_INTEGRATION_METADATA: Record< string, SetupMcpIntegrationMetadata > = { + buildkite: { + capabilities: [ + 'Inspect Buildkite pipelines, builds, jobs, and logs', + 'Review artifacts, annotations, tests, agents, and queues', + 'Keep hosted MCP access read-only at the provider endpoint', + ], + }, asana: { capabilities: [ 'Inspect Asana workspaces, projects, and tasks', diff --git a/packages/slack/src/mcp-recommendations.ts b/packages/slack/src/mcp-recommendations.ts index ba8722fae4..3b3d1976d5 100644 --- a/packages/slack/src/mcp-recommendations.ts +++ b/packages/slack/src/mcp-recommendations.ts @@ -57,6 +57,8 @@ const SLACK_ENABLE_DESCRIPTIONS: Record = { 'Roomote will be able to inspect and manage shared email infrastructure.', braintrust: 'Roomote will be able to inspect prompts, evaluations, and AI run history.', + buildkite: + 'Roomote will be able to inspect Buildkite pipelines, builds, jobs, logs, tests, artifacts, and agents through a read-only connection.', linear: 'Roomote will be able to pull issue, project, and roadmap context into tasks.', monday: diff --git a/packages/types/src/__tests__/mcp-oauth.test.ts b/packages/types/src/__tests__/mcp-oauth.test.ts index f4c07bc745..55d480dc43 100644 --- a/packages/types/src/__tests__/mcp-oauth.test.ts +++ b/packages/types/src/__tests__/mcp-oauth.test.ts @@ -89,6 +89,21 @@ describe('monday.com OAuth', () => { }); }); +describe('Buildkite OAuth', () => { + it('uses the provider-enforced read-only MCP endpoint with DCR', () => { + expect(getMcpIntegration('buildkite')).toMatchObject({ + name: 'Buildkite', + url: 'https://mcp.buildkite.com/mcp/readonly', + connectionScope: 'deployment', + oauthResource: 'https://mcp.buildkite.com/mcp/readonly', + oauthScopes: ['read'], + oauthScopeMode: 'read-only', + }); + expect(getMcpIntegration('buildkite')?.oauthClientEnv).toBeUndefined(); + expect(getMcpIntegrationDefaultDisabledTools('buildkite')).toEqual([]); + }); +}); + describe('Notion internal integration', () => { it('uses a deployment-scoped native MCP with admin-managed credentials', () => { expect(getMcpIntegration('notion')).toMatchObject({ diff --git a/packages/types/src/mcp-oauth.ts b/packages/types/src/mcp-oauth.ts index 5976586312..b8477c75c0 100644 --- a/packages/types/src/mcp-oauth.ts +++ b/packages/types/src/mcp-oauth.ts @@ -652,6 +652,19 @@ export const MCP_INTEGRATIONS: McpIntegration[] = [ instructions: 'Sentry advertises only a few tools directly (find_organizations, find_projects, search_issues, search_events, get_sentry_resource). Reach everything else (issue details, event stack traces, breadcrumbs, tag values, issue events, releases, traces, replays, attachments, monitors, alert rules, docs) by calling search_sentry_tools with a short query, then execute_sentry_tool with the returned tool name and arguments. Which tools exist depends on the access the admin granted when connecting. Treat Sentry as read-only unless the request explicitly asks to change Sentry state: do not resolve, assign, ignore, or otherwise update issues, and do not create or modify projects, teams, DSNs, or monitors on your own initiative.', }, + { + id: 'buildkite', + name: 'Buildkite', + url: 'https://mcp.buildkite.com/mcp/readonly', + description: `Inspect Buildkite pipelines, builds, jobs, tests, and agents from ${PRODUCT_NAME} tasks`, + icon: 'buildkite', + connectionScope: 'deployment', + oauthResource: 'https://mcp.buildkite.com/mcp/readonly', + oauthScopes: ['read'], + oauthScopeMode: 'read-only', + instructions: + "Use Buildkite to inspect organizations, pipelines, builds, jobs, logs, artifacts, annotations, tests, clusters, agents, and queues. This connection uses Buildkite's provider-enforced read-only MCP endpoint; do not assume mutation tools are available.", + }, { id: 'pylon', name: 'Pylon', diff --git a/packages/types/src/mcp-service-detection.ts b/packages/types/src/mcp-service-detection.ts index dc10746db7..e7fc783f8a 100644 --- a/packages/types/src/mcp-service-detection.ts +++ b/packages/types/src/mcp-service-detection.ts @@ -90,6 +90,20 @@ const X_APP_PATH_REGEX = /^\/(?:search|explore)(?:\/|$)|^\/i\/(?:lists|communities|spaces)\//; export const SLACK_MCP_SETUP_SERVICES: SlackMcpSetupServiceDefinition[] = [ + { + id: 'buildkite', + name: 'Buildkite', + availabilityKind: 'curated_oauth', + hostSuffixes: ['buildkite.com'], + excludedHostnames: [ + 'buildkite.com', + 'www.buildkite.com', + 'api.buildkite.com', + 'mcp.buildkite.com', + ], + deploymentSettingsPath: '/integrations', + userSettingsPath: '/settings/personal', + }, { id: 'asana', name: 'Asana', From 4c66ef48ee20de08b4bdc97442f7ca6475d12b3a Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:16:38 +0000 Subject: [PATCH 2/5] fix: route Buildkite links to integration setup --- .../slack/helpers/event-normalization.test.ts | 19 +++ .../forwarded-message-context.test.ts | 28 +++++ .../slack/src/forwarded-message-context.ts | 51 +++++++- .../__tests__/mcp-service-detection.test.ts | 32 +++++ packages/types/src/mcp-service-detection.ts | 118 +++++++++++++++++- 5 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 packages/types/src/__tests__/mcp-service-detection.test.ts diff --git a/apps/api/src/handlers/slack/helpers/event-normalization.test.ts b/apps/api/src/handlers/slack/helpers/event-normalization.test.ts index 60162ef0d5..240e800437 100644 --- a/apps/api/src/handlers/slack/helpers/event-normalization.test.ts +++ b/apps/api/src/handlers/slack/helpers/event-normalization.test.ts @@ -74,6 +74,25 @@ describe('event-normalization', () => { ); }); + it('adds Buildkite setup guidance to the active Slack agent context', () => { + const event = { + type: 'app_mention', + channel: 'C123', + user: 'U123', + ts: '1712345678.000150', + text: '<@U_ROOMOTE> inspect https://buildkite.com/acme/pipelines/api', + } as SlackEvent; + + enrichSlackMessageEvent(event); + + expect(event.agentContext).toContain( + 'Slack integration setup recommendations:\n- Buildkite:', + ); + expect(event.text).toContain( + 'Slack integration setup recommendations:\n- Buildkite:', + ); + }); + it('routes an external bot message that explicitly mentions Roomote', () => { const event = { type: 'message', diff --git a/packages/slack/src/__tests__/forwarded-message-context.test.ts b/packages/slack/src/__tests__/forwarded-message-context.test.ts index 921e91ac80..b371316561 100644 --- a/packages/slack/src/__tests__/forwarded-message-context.test.ts +++ b/packages/slack/src/__tests__/forwarded-message-context.test.ts @@ -8,6 +8,7 @@ import { formatSlackBlockLinkContext, formatSlackBlockTextContext, formatSlackForwardedMessageContext, + formatSlackMcpSetupRecommendationContext, } from '../forwarded-message-context'; import type { SlackFile } from '../types'; @@ -202,6 +203,33 @@ describe('forwarded-message-context', () => { ); }); + it('adds a setup recommendation for pasted Buildkite organization URLs', () => { + expect( + formatSlackMcpSetupRecommendationContext( + 'Can you inspect https://buildkite.com/acme/pipelines/api/builds/42?', + ), + ).toBe( + [ + 'Slack integration setup recommendations:', + '- Buildkite: if it is unavailable, offer to connect the built-in integration from /integrations.', + ].join('\n'), + ); + }); + + it('adds the recommendation to active Slack agent context', () => { + expect( + formatSlackAttachmentContext('Can you inspect this?', undefined, [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: '', + }, + }, + ]), + ).toContain('Slack integration setup recommendations:\n- Buildkite:'); + }); + it('extracts Sentry-style block and attachment context without action labels', () => { const context = appendSlackAttachmentContext( 'investigate this sentry error', diff --git a/packages/slack/src/forwarded-message-context.ts b/packages/slack/src/forwarded-message-context.ts index 8b85035549..74bd9bbc17 100644 --- a/packages/slack/src/forwarded-message-context.ts +++ b/packages/slack/src/forwarded-message-context.ts @@ -1,4 +1,9 @@ -import { dataVisualizationBlockSchema } from '@roomote/types'; +import { + dataVisualizationBlockSchema, + findSlackMcpSetupServicesInText, + matchSlackMcpSetupServiceUrl, + type SlackMcpSetupServiceDefinition, +} from '@roomote/types'; import type { SlackFile } from './types'; @@ -1165,6 +1170,44 @@ export function formatSlackBlockLinkContext( ].join('\n'); } +export function formatSlackMcpSetupRecommendationContext( + text: string, + blocks?: unknown[], + attachments?: unknown[], +): string | undefined { + const services = new Map(); + for (const service of findSlackMcpSetupServicesInText(text)) { + services.set(service.id, service); + } + + const links: SlackBlockLink[] = []; + const seenKeys = new Set(); + extractBlockLinks(blocks, links, seenKeys); + for (const attachment of attachments ?? []) { + if (isRecord(attachment)) { + extractBlockLinks(attachment.blocks, links, seenKeys); + } + } + for (const link of links) { + const service = matchSlackMcpSetupServiceUrl(link.url); + if (service) { + services.set(service.id, service); + } + } + + if (services.size === 0) { + return undefined; + } + + return [ + 'Slack integration setup recommendations:', + ...[...services.values()].map( + (service) => + `- ${service.name}: if it is unavailable, offer to connect the built-in integration from ${service.deploymentSettingsPath}.`, + ), + ].join('\n'); +} + export function formatSlackBlockTextContext( blocks?: unknown[], existingText = '', @@ -1303,11 +1346,17 @@ export function formatSlackAttachmentContext( textWithForwardedContext, ); const blockLinkContext = formatSlackBlockLinkContext(blocks, attachments); + const integrationSetupContext = formatSlackMcpSetupRecommendationContext( + text, + blocks, + attachments, + ); const additionalContexts = [ formatSlackForwardedMessageContext(attachments), attachmentTitleContext, blockTextContext, blockLinkContext, + integrationSetupContext, ].filter((context): context is string => Boolean(context)); if (additionalContexts.length === 0) { diff --git a/packages/types/src/__tests__/mcp-service-detection.test.ts b/packages/types/src/__tests__/mcp-service-detection.test.ts new file mode 100644 index 0000000000..9f281f9acd --- /dev/null +++ b/packages/types/src/__tests__/mcp-service-detection.test.ts @@ -0,0 +1,32 @@ +import { + findSlackMcpSetupServicesInText, + matchSlackMcpSetupServiceUrl, +} from '../mcp-service-detection'; + +describe('Slack MCP setup service detection', () => { + it('matches Buildkite organization URLs from plain and Slack-formatted text', () => { + expect( + matchSlackMcpSetupServiceUrl( + 'https://buildkite.com/acme/pipelines/api/builds/42', + )?.id, + ).toBe('buildkite'); + + expect( + findSlackMcpSetupServicesInText( + 'Check .', + ).map((service) => service.id), + ).toEqual(['buildkite']); + }); + + it('does not match Buildkite public, API, or MCP URLs', () => { + expect( + matchSlackMcpSetupServiceUrl('https://buildkite.com/docs/pipelines'), + ).toBeUndefined(); + expect( + matchSlackMcpSetupServiceUrl('https://api.buildkite.com/v2/builds'), + ).toBeUndefined(); + expect( + matchSlackMcpSetupServiceUrl('https://mcp.buildkite.com/mcp/readonly'), + ).toBeUndefined(); + }); +}); diff --git a/packages/types/src/mcp-service-detection.ts b/packages/types/src/mcp-service-detection.ts index e7fc783f8a..4e3e80e576 100644 --- a/packages/types/src/mcp-service-detection.ts +++ b/packages/types/src/mcp-service-detection.ts @@ -89,6 +89,24 @@ const X_POST_PATH_REGEX = /^\/[a-z0-9_]{1,15}\/status\/\d+/; const X_APP_PATH_REGEX = /^\/(?:search|explore)(?:\/|$)|^\/i\/(?:lists|communities|spaces)\//; +const BUILDKITE_PUBLIC_ROOT_SEGMENTS = [ + 'about', + 'blog', + 'changelog', + 'community', + 'customers', + 'docs', + 'features', + 'legal', + 'pricing', + 'resources', + 'security', + 'support', +] as const; +const BUILDKITE_ORGANIZATION_PATH_REGEX = new RegExp( + `^/(?!(?:${BUILDKITE_PUBLIC_ROOT_SEGMENTS.join('|')})(?:/|$))[^/]+(?:/|$)`, +); + export const SLACK_MCP_SETUP_SERVICES: SlackMcpSetupServiceDefinition[] = [ { id: 'buildkite', @@ -96,11 +114,16 @@ export const SLACK_MCP_SETUP_SERVICES: SlackMcpSetupServiceDefinition[] = [ availabilityKind: 'curated_oauth', hostSuffixes: ['buildkite.com'], excludedHostnames: [ - 'buildkite.com', 'www.buildkite.com', 'api.buildkite.com', 'mcp.buildkite.com', ], + hostRules: [ + { + hostSuffix: 'buildkite.com', + pathRegexes: [BUILDKITE_ORGANIZATION_PATH_REGEX], + }, + ], deploymentSettingsPath: '/integrations', userSettingsPath: '/settings/personal', }, @@ -352,3 +375,96 @@ export function getSlackMcpSetupServiceDefinition( ): SlackMcpSetupServiceDefinition | undefined { return SLACK_MCP_SETUP_SERVICES.find((service) => service.id === id); } + +function hostMatchesService(hostname: string, suffix: string): boolean { + const normalizedSuffix = suffix.toLowerCase(); + return ( + hostname === normalizedSuffix || hostname.endsWith(`.${normalizedSuffix}`) + ); +} + +function pathMatchesServiceRule( + pathname: string, + rule: Pick & { + pathRegexes?: RegExp[]; + }, +): boolean { + if ( + rule.pathPrefixes?.some((prefix) => + pathname.startsWith(prefix.toLowerCase()), + ) + ) { + return true; + } + + if (rule.pathRegexes?.some((pattern) => pattern.test(pathname))) { + return true; + } + + return !rule.pathPrefixes?.length && !rule.pathRegexes?.length; +} + +export function matchSlackMcpSetupServiceUrl( + rawUrl: string, +): SlackMcpSetupServiceDefinition | undefined { + let parsed: URL; + try { + parsed = new URL(rawUrl.startsWith('www.') ? `https://${rawUrl}` : rawUrl); + } catch { + return undefined; + } + + const hostname = parsed.hostname.toLowerCase(); + const pathname = parsed.pathname.toLowerCase(); + + for (const service of SLACK_MCP_SETUP_SERVICES) { + if ( + service.excludedHostnames?.some( + (excluded) => excluded.toLowerCase() === hostname, + ) + ) { + continue; + } + + if (service.hostRules?.length) { + if ( + service.hostRules.some( + (rule) => + hostMatchesService(hostname, rule.hostSuffix) && + pathMatchesServiceRule(pathname, rule), + ) + ) { + return service; + } + continue; + } + + if ( + service.hostSuffixes.some((suffix) => + hostMatchesService(hostname, suffix), + ) && + pathMatchesServiceRule(pathname, service) + ) { + return service; + } + } + + return undefined; +} + +export function findSlackMcpSetupServicesInText( + text: string, +): SlackMcpSetupServiceDefinition[] { + const services = new Map(); + const urlPattern = /(?:https?:\/\/|www\.)[^\s<>()|]+/giu; + + for (const match of text.matchAll(urlPattern)) { + const candidate = match[0].replace(/[,.!?]+$/u, ''); + const service = matchSlackMcpSetupServiceUrl(candidate); + if (service) { + services.set(service.id, service); + } + } + + return [...services.values()]; +} From 7524dd3b27c68615ff9d07c22c98bede1ce80a10 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:21:16 +0000 Subject: [PATCH 3/5] fix: avoid slow URL punctuation matching --- .../__tests__/mcp-service-detection.test.ts | 2 +- packages/types/src/mcp-service-detection.ts | 20 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/types/src/__tests__/mcp-service-detection.test.ts b/packages/types/src/__tests__/mcp-service-detection.test.ts index 9f281f9acd..f3bda4fe5f 100644 --- a/packages/types/src/__tests__/mcp-service-detection.test.ts +++ b/packages/types/src/__tests__/mcp-service-detection.test.ts @@ -13,7 +13,7 @@ describe('Slack MCP setup service detection', () => { expect( findSlackMcpSetupServicesInText( - 'Check .', + 'Check !!!!', ).map((service) => service.id), ).toEqual(['buildkite']); }); diff --git a/packages/types/src/mcp-service-detection.ts b/packages/types/src/mcp-service-detection.ts index 4e3e80e576..5096fa01dd 100644 --- a/packages/types/src/mcp-service-detection.ts +++ b/packages/types/src/mcp-service-detection.ts @@ -452,6 +452,24 @@ export function matchSlackMcpSetupServiceUrl( return undefined; } +function stripTrailingUrlPunctuation(value: string): string { + let end = value.length; + while (end > 0) { + const character = value[end - 1]; + if ( + character === ',' || + character === '.' || + character === '!' || + character === '?' + ) { + end -= 1; + continue; + } + break; + } + return value.slice(0, end); +} + export function findSlackMcpSetupServicesInText( text: string, ): SlackMcpSetupServiceDefinition[] { @@ -459,7 +477,7 @@ export function findSlackMcpSetupServicesInText( const urlPattern = /(?:https?:\/\/|www\.)[^\s<>()|]+/giu; for (const match of text.matchAll(urlPattern)) { - const candidate = match[0].replace(/[,.!?]+$/u, ''); + const candidate = stripTrailingUrlPunctuation(match[0]); const service = matchSlackMcpSetupServiceUrl(candidate); if (service) { services.set(service.id, service); From 2b6bfefb0cd7b7acf2ffb9bdb6196fc49c3d8111 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:24:03 +0000 Subject: [PATCH 4/5] fix: detect Buildkite attachment links --- .../src/__tests__/forwarded-message-context.test.ts | 11 +++++++++++ packages/slack/src/forwarded-message-context.ts | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/packages/slack/src/__tests__/forwarded-message-context.test.ts b/packages/slack/src/__tests__/forwarded-message-context.test.ts index b371316561..a7f7a739cb 100644 --- a/packages/slack/src/__tests__/forwarded-message-context.test.ts +++ b/packages/slack/src/__tests__/forwarded-message-context.test.ts @@ -230,6 +230,17 @@ describe('forwarded-message-context', () => { ).toContain('Slack integration setup recommendations:\n- Buildkite:'); }); + it('detects Buildkite URLs exposed only through attachment title links', () => { + expect( + formatSlackAttachmentContext('Can you inspect this?', [ + { + title: 'Build #42', + title_link: 'https://buildkite.com/acme/pipelines/api/builds/42', + }, + ]), + ).toContain('Slack integration setup recommendations:\n- Buildkite:'); + }); + it('extracts Sentry-style block and attachment context without action labels', () => { const context = appendSlackAttachmentContext( 'investigate this sentry error', diff --git a/packages/slack/src/forwarded-message-context.ts b/packages/slack/src/forwarded-message-context.ts index 74bd9bbc17..f6eecd64a5 100644 --- a/packages/slack/src/forwarded-message-context.ts +++ b/packages/slack/src/forwarded-message-context.ts @@ -1185,6 +1185,10 @@ export function formatSlackMcpSetupRecommendationContext( extractBlockLinks(blocks, links, seenKeys); for (const attachment of attachments ?? []) { if (isRecord(attachment)) { + const titleLink = getStringField(attachment, 'title_link'); + if (titleLink) { + appendUniqueSlackBlockLink(links, seenKeys, { url: titleLink }); + } extractBlockLinks(attachment.blocks, links, seenKeys); } } From e737aea4a525ba08e6ffd8f28a5a8b770ad86c49 Mon Sep 17 00:00:00 2001 From: "@daniel-lxs" <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:24:56 +0000 Subject: [PATCH 5/5] test: cover Buildkite URL punctuation --- packages/types/src/__tests__/mcp-service-detection.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/types/src/__tests__/mcp-service-detection.test.ts b/packages/types/src/__tests__/mcp-service-detection.test.ts index f3bda4fe5f..14889e463d 100644 --- a/packages/types/src/__tests__/mcp-service-detection.test.ts +++ b/packages/types/src/__tests__/mcp-service-detection.test.ts @@ -13,7 +13,12 @@ describe('Slack MCP setup service detection', () => { expect( findSlackMcpSetupServicesInText( - 'Check !!!!', + 'Check .', + ).map((service) => service.id), + ).toEqual(['buildkite']); + expect( + findSlackMcpSetupServicesInText( + 'Check https://buildkite.com/acme/pipelines/api!!!!', ).map((service) => service.id), ).toEqual(['buildkite']); });