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/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/__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/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/__tests__/mcp-service-detection.test.ts b/packages/types/src/__tests__/mcp-service-detection.test.ts
new file mode 100644
index 0000000000..6dda76d06d
--- /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 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']);
+ expect(
+ findSlackMcpSetupServicesInText(
+ 'Check https://dash.cloudflare.com/example/workers!!!!',
+ ).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-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..a4a8551a3a 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',
@@ -338,3 +346,114 @@ 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;
+}
+
+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[] {
+ const services = new Map();
+ const urlPattern = /(?:https?:\/\/|www\.)[^\s<>()|]+/giu;
+
+ for (const match of text.matchAll(urlPattern)) {
+ const candidate = stripTrailingUrlPunctuation(match[0]);
+ const service = matchSlackMcpSetupServiceUrl(candidate);
+ if (service) {
+ services.set(service.id, service);
+ }
+ }
+
+ return [...services.values()];
+}