From b383dab2a13444575a210b333eb642e5f54b8096 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/4] feat: add Cloudflare built-in integration --- apps/docs/docs.json | 1 + apps/docs/integrations/cloudflare.mdx | 28 +++++++++++++++++++ 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 | 11 ++++++++ packages/types/src/mcp-service-detection.ts | 8 ++++++ 11 files changed, 85 insertions(+) create mode 100644 apps/docs/integrations/cloudflare.mdx diff --git a/apps/docs/docs.json b/apps/docs/docs.json index f18c464448..658b35066e 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -155,6 +155,7 @@ "integrations/asana", "integrations/better-stack", "integrations/braintrust", + "integrations/cloudflare", "integrations/elevenlabs", "integrations/exa", "integrations/grafana", diff --git a/apps/docs/integrations/cloudflare.mdx b/apps/docs/integrations/cloudflare.mdx new file mode 100644 index 0000000000..6b1a211f61 --- /dev/null +++ b/apps/docs/integrations/cloudflare.mdx @@ -0,0 +1,28 @@ +--- +title: Cloudflare +description: Inspect and operate Cloudflare resources from Roomote tasks. +icon: 'https://api.iconify.design/simple-icons:cloudflare.svg?color=currentColor' +--- + +Connect Cloudflare when tasks need account, zone, DNS, Workers, security, or +other Cloudflare API context. + +## How setup works + +A deployment operator connects Cloudflare once from **Settings > Integrations** +using OAuth. Cloudflare uses dynamic client registration and asks the operator +to approve the permissions available to the shared deployment connection. + +## Permissions and changes + +Cloudflare exposes one tool for finding API operations and one tool for running +them. The same execution tool can call read and write APIs, so Roomote treats +mutations as explicit actions: it only changes Cloudflare resources when the +request clearly asks for that specific change. Cloudflare still enforces the +permissions granted during OAuth consent. + +## Verify the connection + +Start with a read-only request, such as listing zones or inspecting a Worker. +If the request is denied, reconnect Cloudflare and review the permissions made +available during consent. diff --git a/apps/docs/integrations/index.mdx b/apps/docs/integrations/index.mdx index ae26253607..1eb8aac84b 100644 --- a/apps/docs/integrations/index.mdx +++ b/apps/docs/integrations/index.mdx @@ -79,6 +79,7 @@ from [Personal Settings](/personal-settings). | | Project and task context from Asana | Admin connection once | | | Monitoring and incident context | Admin connection once | | | Prompts, runs, and evaluation context | Enable first, then teammates link accounts | +| | Cloud infrastructure and API operations | Admin connection once | | | Voice narration for feature-demo videos | Admin connection once | | | Keyless web search; optional Agent research | Admin enables; API key optional | | | Dashboards, alerting, and monitoring context | Admin connection once | diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index bd77e8cfa2..ecc41cceff 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.', + cloudflare: + 'Roomote will be able to inspect Cloudflare resources and run explicitly requested API operations with the permissions granted during OAuth.', 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..3011e5ea4d 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, + siCloudflare, siElevenlabs, siDependabot, siDatadog, @@ -42,6 +43,7 @@ const SIMPLE_ICONS: Record = { asana: siAsana, betterstack: siBetterstack, braintrust: siBraintrust, + cloudflare: siCloudflare, 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..d60cd2e6e5 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. +# Cloudflare + +Cloudflare uses OAuth: +1. A deployment operator enables Cloudflare from Settings > Integrations. +2. That operator connects Cloudflare once for the deployment via OAuth. + +Once connected, I can search and inspect Cloudflare resources. The execute tool can also call write APIs, so I only make changes when the user explicitly requests the specific mutation and the OAuth connection permits it. + # 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..a89ca7a782 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 > = { + cloudflare: { + capabilities: [ + 'Search the Cloudflare API for available operations', + 'Inspect zones, Workers, DNS, security, and account resources', + 'Run explicitly requested Cloudflare API operations with the connected permissions', + ], + }, 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..f40ba5881a 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.', + cloudflare: + 'Roomote will be able to inspect Cloudflare resources and run explicitly requested API operations with the connected permissions.', 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..37858f5442 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('Cloudflare OAuth', () => { + it('uses the hosted API MCP with a deployment-scoped DCR connection', () => { + expect(getMcpIntegration('cloudflare')).toMatchObject({ + name: 'Cloudflare', + url: 'https://mcp.cloudflare.com/mcp', + connectionScope: 'deployment', + oauthResource: 'https://mcp.cloudflare.com/mcp', + }); + expect(getMcpIntegration('cloudflare')?.oauthClientEnv).toBeUndefined(); + expect(getMcpIntegration('cloudflare')?.instructions).toContain( + 'only when the user explicitly requests the specific mutation', + ); + }); +}); + 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..5182010bac 100644 --- a/packages/types/src/mcp-oauth.ts +++ b/packages/types/src/mcp-oauth.ts @@ -652,6 +652,17 @@ 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: 'cloudflare', + name: 'Cloudflare', + url: 'https://mcp.cloudflare.com/mcp', + description: `Inspect and operate Cloudflare resources from ${PRODUCT_NAME} tasks`, + icon: 'cloudflare', + connectionScope: 'deployment', + oauthResource: 'https://mcp.cloudflare.com/mcp', + instructions: + "Use Cloudflare to search and inspect the deployment's Cloudflare resources. The execute tool can call both read and write Cloudflare APIs; make changes only when the user explicitly requests the specific mutation, and preserve the permissions granted during OAuth consent.", + }, { id: 'pylon', name: 'Pylon', diff --git a/packages/types/src/mcp-service-detection.ts b/packages/types/src/mcp-service-detection.ts index dc10746db7..c700959e47 100644 --- a/packages/types/src/mcp-service-detection.ts +++ b/packages/types/src/mcp-service-detection.ts @@ -90,6 +90,14 @@ const X_APP_PATH_REGEX = /^\/(?:search|explore)(?:\/|$)|^\/i\/(?:lists|communities|spaces)\//; export const SLACK_MCP_SETUP_SERVICES: SlackMcpSetupServiceDefinition[] = [ + { + id: 'cloudflare', + name: 'Cloudflare', + availabilityKind: 'curated_oauth', + hostSuffixes: ['dash.cloudflare.com'], + deploymentSettingsPath: '/integrations', + userSettingsPath: '/settings/personal', + }, { id: 'asana', name: 'Asana', From 0a5eea44d260b7f91fcba48b5196653bc75a597d 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/4] fix: route Cloudflare 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 | 27 ++++++ packages/types/src/mcp-service-detection.ts | 93 +++++++++++++++++++ 5 files changed, 217 insertions(+), 1 deletion(-) 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..086ad6fde8 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 Cloudflare 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://dash.cloudflare.com/example/workers', + } as SlackEvent; + + enrichSlackMessageEvent(event); + + expect(event.agentContext).toContain( + 'Slack integration setup recommendations:\n- Cloudflare:', + ); + expect(event.text).toContain( + 'Slack integration setup recommendations:\n- Cloudflare:', + ); + }); + 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..d59b970a11 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 Cloudflare dashboard URLs', () => { + expect( + formatSlackMcpSetupRecommendationContext( + 'Can you inspect https://dash.cloudflare.com/example/workers/services/view/api?', + ), + ).toBe( + [ + 'Slack integration setup recommendations:', + '- Cloudflare: 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- Cloudflare:'); + }); + 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..ce21b90813 --- /dev/null +++ b/packages/types/src/__tests__/mcp-service-detection.test.ts @@ -0,0 +1,27 @@ +import { + findSlackMcpSetupServicesInText, + matchSlackMcpSetupServiceUrl, +} from '../mcp-service-detection'; + +describe('Slack MCP setup service detection', () => { + it('matches Cloudflare dashboard URLs from plain and Slack-formatted text', () => { + expect( + matchSlackMcpSetupServiceUrl( + 'https://dash.cloudflare.com/example/workers/services/view/api', + )?.id, + ).toBe('cloudflare'); + + expect( + findSlackMcpSetupServicesInText( + 'Check .', + ).map((service) => service.id), + ).toEqual(['cloudflare']); + }); + + it('does not match unrelated or malformed URLs', () => { + expect( + matchSlackMcpSetupServiceUrl('https://developers.cloudflare.com/agents'), + ).toBeUndefined(); + expect(matchSlackMcpSetupServiceUrl('not a URL')).toBeUndefined(); + }); +}); diff --git a/packages/types/src/mcp-service-detection.ts b/packages/types/src/mcp-service-detection.ts index c700959e47..e8ca55a026 100644 --- a/packages/types/src/mcp-service-detection.ts +++ b/packages/types/src/mcp-service-detection.ts @@ -346,3 +346,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 e0dcb317668fb3123763934a5b917130f85ab43e 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/4] 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 ce21b90813..d84e787774 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(['cloudflare']); }); diff --git a/packages/types/src/mcp-service-detection.ts b/packages/types/src/mcp-service-detection.ts index e8ca55a026..a4a8551a3a 100644 --- a/packages/types/src/mcp-service-detection.ts +++ b/packages/types/src/mcp-service-detection.ts @@ -423,6 +423,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[] { @@ -430,7 +448,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 7bbdfc2540282fdfc720c0b155161379647aed85 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 4/4] test: cover Cloudflare 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 d84e787774..6dda76d06d 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(['cloudflare']); + expect( + findSlackMcpSetupServicesInText( + 'Check https://dash.cloudflare.com/example/workers!!!!', ).map((service) => service.id), ).toEqual(['cloudflare']); });