From ae72f58381fa176d505d9e73bbbd18fe2015b3fa Mon Sep 17 00:00:00 2001 From: Jordan Coeyman Date: Wed, 14 Oct 2026 08:55:15 -0400 Subject: [PATCH 1/2] feat(apps): show_attention, a read-only report of what needs action in an account show_attention lists notifications that fired in the last 7 days, open Security Center insights and active Workers Observability issues, most severe first, as text and structuredContent. It is registered in both tool modes. - Feeds are read with the caller's token. A refused feed names the missing permission, a 5xx is retried, and a feed that fails is reported instead of shown as empty. - An account_id the session cannot reach is rejected before any feed call. Only https://dash.cloudflare.com links are kept. --- src/apps/attention/report.ts | 66 +++++ src/apps/attention/sources.ts | 233 +++++++++++++++++ src/apps/attention/tool.ts | 117 +++++++++ src/server.ts | 2 + src/tools/non-codemode.ts | 8 +- tests/apps/attention.test.ts | 330 +++++++++++++++++++++++++ tests/auth/cimd.test.ts | 3 +- tests/auth/oauth-routes.test.ts | 3 +- tests/auth/token-compatibility.test.ts | 4 +- tests/mcp-client.test.ts | 8 +- tests/mcp-modern.test.ts | 10 +- tests/non-codemode.test.ts | 3 +- 12 files changed, 777 insertions(+), 10 deletions(-) create mode 100644 src/apps/attention/report.ts create mode 100644 src/apps/attention/sources.ts create mode 100644 src/apps/attention/tool.ts create mode 100644 tests/apps/attention.test.ts diff --git a/src/apps/attention/report.ts b/src/apps/attention/report.ts new file mode 100644 index 0000000..0085d97 --- /dev/null +++ b/src/apps/attention/report.ts @@ -0,0 +1,66 @@ +import { z } from 'zod' + +export const SourceSchema = z.enum(['alerts', 'security', 'workers']) +export type Source = z.infer + +export const SeveritySchema = z.enum(['critical', 'warning', 'info']) +export type Severity = z.infer + +export const AttentionItemSchema = z.strictObject({ + id: z.string(), + source: SourceSchema, + severity: SeveritySchema, + title: z.string(), + detail: z.string(), + observedAt: z.string().optional(), + link: z.string().optional(), + followUp: z.string() +}) +export type AttentionItem = z.infer + +export const UnavailableSourceSchema = z.strictObject({ + source: SourceSchema, + reason: z.string() +}) +export type UnavailableSource = z.infer + +export const AttentionReportSchema = z.strictObject({ + accountId: z.string(), + generatedAt: z.string(), + items: z.array(AttentionItemSchema), + unavailable: z.array(UnavailableSourceSchema) +}) +export type AttentionReport = z.infer + +const SEVERITY_RANK: Record = { critical: 0, warning: 1, info: 2 } + +export function sortItems(items: readonly AttentionItem[]): AttentionItem[] { + return [...items].sort( + (a, b) => + SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || + (b.observedAt ?? '').localeCompare(a.observedAt ?? '') + ) +} + +const SOURCE_LABEL: Record = { + alerts: 'Alerts', + security: 'Security insights', + workers: 'Workers issues' +} + +export function reportText(report: AttentionReport): string { + const lines = report.items.map( + (item, index) => `${index + 1}. [${item.severity}] ${item.title}. ${item.detail}` + ) + const gaps = report.unavailable.map( + ({ source, reason }) => `${SOURCE_LABEL[source]} could not be loaded: ${reason}` + ) + const count = report.items.length + const headline = + count > 0 + ? `${count} ${count === 1 ? 'thing needs' : 'things need'} attention in account ${report.accountId}:` + : report.unavailable.length > 0 + ? `Nothing needs attention in the feeds that loaded for account ${report.accountId}.` + : `Nothing needs attention in account ${report.accountId}.` + return [headline, ...lines, ...gaps].join('\n') +} diff --git a/src/apps/attention/sources.ts b/src/apps/attention/sources.ts new file mode 100644 index 0000000..63bf24d --- /dev/null +++ b/src/apps/attention/sources.ts @@ -0,0 +1,233 @@ +import { z } from 'zod' +import { fetchWithRetry } from '../../utils/fetch-retry' +import type { AttentionItem, Severity, Source, UnavailableSource } from './report' + +const ALERT_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000 +const PAGE_SIZE = 25 +const MAX_SERVER_ERROR_ATTEMPTS = 3 +const SERVER_ERROR_RETRY_MS = 250 + +const EnvelopeSchema = z.looseObject({ + success: z.boolean(), + errors: z.array(z.looseObject({ message: z.string() })).optional(), + result: z.unknown() +}) + +const AlertSchema = z.looseObject({ + id: z.string(), + name: z.string().optional(), + alert_type: z.string().optional(), + alert_body: z.string().optional(), + description: z.string().optional(), + sent: z.string().optional() +}) + +const SecurityInsightSchema = z.looseObject({ + id: z.string(), + subject: z.string().optional(), + issue_type: z.string().optional(), + issue_class: z.string().optional(), + severity: z.enum(['Low', 'Moderate', 'Critical']).optional(), + resolve_text: z.string().optional(), + resolve_link: z.string().optional(), + timestamp: z.string().optional() +}) + +const SecurityResultSchema = z.looseObject({ issues: z.array(SecurityInsightSchema).default([]) }) + +const WorkersIssueSchema = z.looseObject({ + id: z.string(), + service: z.string(), + title: z.string(), + count: z.number(), + lastObserved: z.number() +}) + +export interface FeedRequest { + apiBase: string + accessToken: string + accountId: string + now: Date +} + +export type FeedResult = + | { ok: true; items: AttentionItem[] } + | { ok: false; unavailable: UnavailableSource } + +class FeedError extends Error {} + +const MISSING_PERMISSION: Record = { + alerts: 'Notifications Read', + security: 'Account Security Center Insights Read', + workers: 'Workers Observability Read' +} + +const FEED_PATH: Record = { + alerts: 'alerting/v3/history', + security: 'security-center/insights', + workers: 'workers/observability/issues' +} + +class PermissionError extends FeedError {} + +function missingPermissionReason(source: Source): string { + return `This token can't read it. Add the ${MISSING_PERMISSION[source]} permission.` +} + +async function fetchResult(request: FeedRequest, path: string, query: URLSearchParams) { + const url = `${request.apiBase}/accounts/${encodeURIComponent(request.accountId)}/${path}?${query}` + let lastStatus = 0 + for (let attempt = 1; attempt <= MAX_SERVER_ERROR_ATTEMPTS; attempt++) { + const response = await fetchWithRetry( + url, + { headers: { Authorization: `Bearer ${request.accessToken}` } }, + { caller: 'attention_panel' } + ) + lastStatus = response.status + if (response.status === 401 || response.status === 403) { + await response.body?.cancel() + throw new PermissionError(path) + } + if (response.status < 500) return parseEnvelope(response) + await response.body?.cancel() + if (attempt < MAX_SERVER_ERROR_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, SERVER_ERROR_RETRY_MS * attempt)) + } + } + throw new FeedError(`Cloudflare API returned ${lastStatus}`) +} + +async function parseEnvelope(response: Response): Promise { + const envelope = EnvelopeSchema.safeParse(await response.json().catch(() => undefined)) + if (!envelope.success) throw new FeedError(`unexpected response (${response.status})`) + if (!envelope.data.success) { + const message = envelope.data.errors?.map((error) => error.message).join('; ') + throw new FeedError(message || `request failed (${response.status})`) + } + return envelope.data.result +} + +function isoFromEpoch(value: number): string { + const milliseconds = value < 1e12 ? value * 1000 : value + return new Date(milliseconds).toISOString() +} + +function alertItem(alert: z.infer): AttentionItem { + const title = alert.name || alert.alert_type || 'Notification' + const detail = alert.alert_body || alert.description || alert.alert_type || '' + return { + id: `alert:${alert.id}`, + source: 'alerts', + severity: 'warning', + title, + detail, + observedAt: alert.sent, + followUp: `A Cloudflare notification fired${alert.sent ? ` at ${alert.sent}` : ''}: "${title}". ${detail} Find out what triggered it and whether I need to act.` + } +} + +const SECURITY_SEVERITY: Record<'Low' | 'Moderate' | 'Critical', Severity> = { + Critical: 'critical', + Moderate: 'warning', + Low: 'info' +} + +function dashboardLink(value: string | undefined): string | undefined { + if (!value || !URL.canParse(value)) return undefined + const url = new URL(value) + return url.protocol === 'https:' && url.hostname === 'dash.cloudflare.com' ? url.href : undefined +} + +function securityItem(insight: z.infer): AttentionItem { + const kind = (insight.issue_type ?? 'security insight').replaceAll('_', ' ') + const subject = insight.subject ?? 'this account' + const detail = insight.resolve_text || insight.issue_class?.replaceAll('_', ' ') || kind + return { + id: `security:${insight.id}`, + source: 'security', + severity: insight.severity ? SECURITY_SEVERITY[insight.severity] : 'info', + title: `${kind} on ${subject}`, + detail, + observedAt: insight.timestamp, + link: dashboardLink(insight.resolve_link), + followUp: `Cloudflare Security Center flagged ${kind} on ${subject}: ${detail}. Explain the risk and walk me through fixing it.` + } +} + +const COUNT_FORMAT = new Intl.NumberFormat('en-US') + +const TIME_FORMAT = new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + timeZone: 'UTC' +}) + +function occurrenceText(count: number): string { + return count === 1 ? 'once' : `${COUNT_FORMAT.format(count)} times` +} + +function workersItem(issue: z.infer): AttentionItem { + const lastSeen = isoFromEpoch(issue.lastObserved) + const seen = `Seen ${occurrenceText(issue.count)}, last on ${TIME_FORMAT.format(new Date(lastSeen))} UTC.` + return { + id: `workers:${issue.id}`, + source: 'workers', + severity: 'warning', + title: `${issue.service}: ${issue.title}`, + detail: seen, + observedAt: lastSeen, + followUp: `Workers Observability detected an issue in the Worker "${issue.service}": "${issue.title}", seen ${issue.count} times, last at ${lastSeen}. Find the cause and suggest a fix.` + } +} + +async function alertItems(request: FeedRequest): Promise { + const since = new Date(request.now.getTime() - ALERT_LOOKBACK_MS).toISOString() + const query = new URLSearchParams({ since, per_page: String(PAGE_SIZE) }) + const result = z + .array(AlertSchema) + .nullable() + .parse(await fetchResult(request, FEED_PATH.alerts, query)) + return (result ?? []).map(alertItem) +} + +async function securityItems(request: FeedRequest): Promise { + const query = new URLSearchParams({ dismissed: 'false', per_page: String(PAGE_SIZE) }) + const result = SecurityResultSchema.parse(await fetchResult(request, FEED_PATH.security, query)) + return result.issues.map(securityItem) +} + +async function workersItems(request: FeedRequest): Promise { + const query = new URLSearchParams({ + status: 'active', + orderBy: 'lastObserved', + order: 'desc', + perPage: String(PAGE_SIZE) + }) + const result = z + .array(WorkersIssueSchema) + .parse(await fetchResult(request, FEED_PATH.workers, query)) + return result.map(workersItem) +} + +const FEEDS: Record Promise> = { + alerts: alertItems, + security: securityItems, + workers: workersItems +} + +function reasonFor(source: Source, error: unknown): string { + if (error instanceof PermissionError) return missingPermissionReason(source) + if (error instanceof FeedError) return error.message + if (error instanceof z.ZodError) return 'unexpected response shape' + return error instanceof Error ? error.message : String(error) +} + +export async function loadFeed(source: Source, request: FeedRequest): Promise { + try { + return { ok: true, items: await FEEDS[source](request) } + } catch (error) { + return { ok: false, unavailable: { source, reason: reasonFor(source, error) } } + } +} + +export const SOURCES: readonly Source[] = ['alerts', 'security', 'workers'] diff --git a/src/apps/attention/tool.ts b/src/apps/attention/tool.ts new file mode 100644 index 0000000..21cc747 --- /dev/null +++ b/src/apps/attention/tool.ts @@ -0,0 +1,117 @@ +import { z } from 'zod' +import { env } from 'cloudflare:workers' +import type { CallToolResult, McpServer, Tool } from '@modelcontextprotocol/server' +import { + ACCOUNT_DISCOVERY_GUIDANCE, + autoResolvedAccountId, + missingAccountMessage, + unknownAccountHint +} from '../../auth/account-access' +import type { AuthProps } from '../../auth/types' +import { AttentionReportSchema, reportText, sortItems, type AttentionReport } from './report' +import { SOURCES, loadFeed, type FeedRequest } from './sources' + +const NAME = 'show_attention' +const TITLE = 'Cloudflare: needs attention' +const DESCRIPTION = + 'Show what in a Cloudflare account needs action now: notifications that fired in the last 7 days, open Security Center insights, and active Workers Observability issues. ' + + 'Each item says what to look into next. Read-only. ' + + 'Pass account_id when the connection can reach more than one account.' + +const READ_SCOPES = [ + 'notifications.read', + 'account-security-center-insights.read', + 'workers-observability.read' +] + +export const AttentionInputSchema = z.strictObject({ + account_id: z + .string() + .regex(/^[0-9a-f]{32}$/) + .optional() + .describe('Cloudflare account ID. Optional when the connection reaches exactly one account.') +}) + +const ANNOTATIONS = { + title: TITLE, + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false +} + +const META = { + securitySchemes: [{ type: 'oauth2', scopes: READ_SCOPES }] +} + +export const ATTENTION_TOOL: Tool = { + name: NAME, + title: TITLE, + description: DESCRIPTION, + inputSchema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + account_id: { + type: 'string', + pattern: '^[0-9a-f]{32}$', + description: + 'Cloudflare account ID. Optional when the connection reaches exactly one account.' + } + }, + additionalProperties: false + }, + annotations: ANNOTATIONS, + _meta: META +} + +export async function buildAttentionReport(request: FeedRequest): Promise { + const results = await Promise.all(SOURCES.map((source) => loadFeed(source, request))) + return { + accountId: request.accountId, + generatedAt: request.now.toISOString(), + items: sortItems(results.flatMap((result) => (result.ok ? result.items : []))), + unavailable: results.flatMap((result) => (result.ok ? [] : [result.unavailable])) + } +} + +function toolError(text: string): CallToolResult { + return { content: [{ type: 'text', text }], isError: true } +} + +export async function runAttentionTool( + props: AuthProps, + input: z.infer, + now = new Date() +): Promise { + const accountId = input.account_id ?? autoResolvedAccountId(props) + if (!accountId) return toolError(missingAccountMessage(props, ACCOUNT_DISCOVERY_GUIDANCE)) + const unknownAccount = unknownAccountHint(props, accountId) + if (unknownAccount) return toolError(unknownAccount) + const report = AttentionReportSchema.parse( + await buildAttentionReport({ + apiBase: env.CLOUDFLARE_API_BASE, + accessToken: props.accessToken, + accountId, + now + }) + ) + return { + content: [{ type: 'text', text: reportText(report) }], + structuredContent: report, + isError: report.items.length === 0 && report.unavailable.length === SOURCES.length + } +} + +export function registerAttentionTool(server: McpServer, props: AuthProps): void { + server.registerTool( + NAME, + { + title: TITLE, + description: DESCRIPTION, + inputSchema: AttentionInputSchema, + annotations: { ...ANNOTATIONS }, + _meta: META + }, + (input) => runAttentionTool(props, input) + ) +} diff --git a/src/server.ts b/src/server.ts index a64760c..9d6f87e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,6 +4,7 @@ import { registerNonCodemodeTools } from './tools/non-codemode' import { registerSearchTool } from './tools/search' import { registerExecuteTool } from './tools/execute' import { registerWhoamiTool } from './tools/whoami' +import { registerAttentionTool } from './apps/attention/tool' import { registerSkills } from './skills/handlers' import { attachMetrics } from './metrics' import { SERVER_INFO } from './constants' @@ -61,6 +62,7 @@ export async function createServer( await registerSearchTool(server, formatResult) registerExecuteTool(server, props, formatResult) registerWhoamiTool(server, props) + registerAttentionTool(server, props) return server } diff --git a/src/tools/non-codemode.ts b/src/tools/non-codemode.ts index 303f94d..1b13179 100644 --- a/src/tools/non-codemode.ts +++ b/src/tools/non-codemode.ts @@ -13,6 +13,7 @@ import { import { recordToolCall } from '../metrics' import { DOCS_TOOL, runDocsTool } from './docs-search' import { WHOAMI_TOOL, WhoamiInputSchema, runWhoamiTool } from './whoami' +import { ATTENTION_TOOL, AttentionInputSchema, runAttentionTool } from '../apps/attention/tool' import { zodInputSchemaFromJson, type McpTool } from '../openapi' import type { AuthProps } from '../auth/types' @@ -33,7 +34,7 @@ export async function registerNonCodemodeTools( const tools = await getMcpTools() server.server.setRequestHandler('tools/list', () => ({ - tools: [DOCS_TOOL, WHOAMI_TOOL, ...tools.list] + tools: [DOCS_TOOL, WHOAMI_TOOL, ATTENTION_TOOL, ...tools.list] })) server.server.setRequestHandler('tools/call', async (request) => { @@ -49,6 +50,11 @@ export async function registerNonCodemodeTools( } else if (name === WHOAMI_TOOL.name) { const parsed = WhoamiInputSchema.safeParse(request.params.arguments ?? {}) result = parsed.success ? runWhoamiTool(props) : validationError(name, parsed.error) + } else if (name === ATTENTION_TOOL.name) { + const parsed = AttentionInputSchema.safeParse(request.params.arguments ?? {}) + result = parsed.success + ? await runAttentionTool(props, parsed.data) + : validationError(name, parsed.error) } else { const tool = tools.byName.get(name) if (!tool) { diff --git a/tests/apps/attention.test.ts b/tests/apps/attention.test.ts new file mode 100644 index 0000000..723d6b0 --- /dev/null +++ b/tests/apps/attention.test.ts @@ -0,0 +1,330 @@ +import { env } from 'cloudflare:workers' +import { http, HttpResponse } from 'msw' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ATTENTION_TOOL, runAttentionTool } from '../../src/apps/attention/tool' +import { AttentionReportSchema } from '../../src/apps/attention/report' +import { AUTH_PROPS_VERSION, type AuthProps } from '../../src/auth/types' +import { API_BASE, cfError, cfSuccess, mockIdentityProbe } from '../helpers/cloudflare-api' +import { clearKv } from '../helpers/kv' +import { modernMcpRequest, parseMcpResult } from '../helpers/mcp' +import { clearSpec, seedSpec } from '../helpers/spec' +import { server } from '../setup/msw' +import { exports } from 'cloudflare:workers' + +const ACCOUNT_ID = '023e105f4ecef8ad9ca31a8372d0c353' +const OTHER_ACCOUNT_ID = 'aaaabbbbccccddddeeeeffff00001111' +const NOW = new Date('2026-10-10T12:00:00Z') +const ACCOUNT_PATH = `${API_BASE}/accounts/${ACCOUNT_ID}` + +const singleAccount: AuthProps = { + type: 'user_token', + accessToken: 'user-token', + user: { id: 'user-1', email: 'alice@example.com' }, + accounts: [{ id: ACCOUNT_ID, name: 'Alice Co' }], + version: AUTH_PROPS_VERSION +} + +const multiAccount: AuthProps = { + ...singleAccount, + accounts: [ + { id: ACCOUNT_ID, name: 'Alice Co' }, + { id: OTHER_ACCOUNT_ID, name: 'Side Project' } + ] +} + +const ALERT = { + id: 'alert-1', + name: 'Origin error rate', + alert_type: 'http_alert_origin_error', + alert_body: '5xx rate above 5% on example.com', + sent: '2026-10-10T11:00:00Z' +} + +const INSIGHT = { + id: 'insight-1', + subject: 'example.com', + issue_type: 'insecure_configuration', + issue_class: 'always_use_https_not_enabled', + severity: 'Critical', + resolve_text: 'Turn on Always Use HTTPS', + resolve_link: 'https://dash.cloudflare.com/example/ssl-tls/edge-certificates', + timestamp: '2026-10-09T08:00:00Z' +} + +const WORKERS_ISSUE = { + id: 'issue-1', + service: 'checkout', + title: 'TypeError: cannot read properties of undefined', + count: 42, + lastObserved: Date.parse('2026-10-10T11:30:00Z') +} + +function mockFeeds( + overrides: Partial Response>> = {} +) { + const seen: Record = {} + server.use( + http.get(`${ACCOUNT_PATH}/alerting/v3/history`, ({ request }) => { + seen.alerts = new URL(request.url) + return overrides.alerts?.() ?? HttpResponse.json(cfSuccess([ALERT])) + }), + http.get(`${ACCOUNT_PATH}/security-center/insights`, ({ request }) => { + seen.security = new URL(request.url) + return ( + overrides.security?.() ?? + HttpResponse.json(cfSuccess({ issues: [INSIGHT], count: 1, page: 1, per_page: 25 })) + ) + }), + http.get(`${ACCOUNT_PATH}/workers/observability/issues`, ({ request }) => { + seen.workers = new URL(request.url) + return overrides.workers?.() ?? HttpResponse.json(cfSuccess([WORKERS_ISSUE])) + }) + ) + return seen +} + +function reportOf(result: Awaited>) { + return AttentionReportSchema.parse(result.structuredContent) +} + +describe('show_attention definition', () => { + it('is a read-only tool', () => { + expect(ATTENTION_TOOL).toMatchObject({ + name: 'show_attention', + annotations: { readOnlyHint: true, destructiveHint: false } + }) + }) + + it('asks only for read scopes', () => { + const [scheme] = ATTENTION_TOOL._meta?.securitySchemes as Array<{ scopes: string[] }> + expect(scheme.scopes.every((scope) => scope.endsWith('.read'))).toBe(true) + }) +}) + +describe('show_attention result', () => { + it('merges all three feeds, most severe first', async () => { + mockFeeds() + const report = reportOf(await runAttentionTool(singleAccount, {}, NOW)) + + expect(report.unavailable).toEqual([]) + expect(report.items.map((item) => item.id)).toEqual([ + 'security:insight-1', + 'workers:issue-1', + 'alert:alert-1' + ]) + expect(report.items[0]).toMatchObject({ + severity: 'critical', + title: 'insecure configuration on example.com', + link: INSIGHT.resolve_link + }) + expect(report.items[1].observedAt).toBe('2026-10-10T11:30:00.000Z') + }) + + it('asks each feed only for what needs action', async () => { + const seen = mockFeeds() + await runAttentionTool(singleAccount, {}, NOW) + + expect(seen.alerts.searchParams.get('since')).toBe('2026-10-03T12:00:00.000Z') + expect(seen.security.searchParams.get('dismissed')).toBe('false') + expect(seen.workers.searchParams.get('status')).toBe('active') + }) + + it('links only to the Cloudflare dashboard', async () => { + mockFeeds({ + security: () => + HttpResponse.json( + cfSuccess({ + issues: [ + { ...INSIGHT, id: 'a', resolve_link: 'https://evil.example/phish' }, + { ...INSIGHT, id: 'b', resolve_link: 'javascript:alert(1)' } + ] + }) + ) + }) + const report = reportOf(await runAttentionTool(singleAccount, {}, NOW)) + + expect( + report.items.filter((item) => item.source === 'security').map((item) => item.link) + ).toEqual([undefined, undefined]) + }) + + it('writes the Workers issue count and time for people to read', async () => { + mockFeeds({ + workers: () => + HttpResponse.json( + cfSuccess([ + { ...WORKERS_ISSUE, id: 'many', count: 527069 }, + { ...WORKERS_ISSUE, id: 'one', count: 1 } + ]) + ) + }) + const report = reportOf(await runAttentionTool(singleAccount, {}, NOW)) + const details = report.items + .filter((item) => item.source === 'workers') + .map((item) => item.detail) + + expect(details).toEqual([ + 'Seen 527,069 times, last on Oct 10, 2026, 11:30 AM UTC.', + 'Seen once, last on Oct 10, 2026, 11:30 AM UTC.' + ]) + }) + + it('writes a follow-up prompt the agent can act on', async () => { + mockFeeds() + const report = reportOf(await runAttentionTool(singleAccount, {}, NOW)) + const workers = report.items.find((item) => item.source === 'workers') + + expect(workers?.followUp).toContain('"checkout"') + expect(workers?.followUp).toContain('Find the cause') + }) + + it('retries a feed that returns a server error', async () => { + let calls = 0 + mockFeeds({ + security: () => { + calls += 1 + return calls === 1 + ? HttpResponse.json(cfError([{ code: 10000, message: 'Internal Server Error' }]), { + status: 500 + }) + : HttpResponse.json(cfSuccess({ issues: [INSIGHT] })) + } + }) + const report = reportOf(await runAttentionTool(singleAccount, {}, NOW)) + + expect(calls).toBe(2) + expect(report.unavailable).toEqual([]) + expect(report.items.some((item) => item.source === 'security')).toBe(true) + }) + + it('reports a failed feed instead of calling the account healthy', async () => { + mockFeeds({ + workers: () => + HttpResponse.json(cfError([{ code: 10000, message: 'Authentication error' }]), { + status: 403 + }) + }) + const result = await runAttentionTool(singleAccount, {}, NOW) + const report = reportOf(result) + + expect(report.unavailable).toEqual([ + { + source: 'workers', + reason: "This token can't read it. Add the Workers Observability Read permission." + } + ]) + expect(result.isError).toBe(false) + expect(result.content[0]).toMatchObject({ + text: expect.not.stringContaining(`Nothing needs attention in account ${ACCOUNT_ID}.`) + }) + expect(result.content[0]).toMatchObject({ + text: expect.stringContaining( + "Workers issues could not be loaded: This token can't read it. Add the Workers Observability Read permission." + ) + }) + }) + + it('names the missing permission for each feed', async () => { + const denied = () => + HttpResponse.json(cfError([{ code: 10000, message: 'Authentication error' }]), { + status: 403 + }) + mockFeeds({ alerts: denied, security: denied }) + const report = reportOf(await runAttentionTool(singleAccount, {}, NOW)) + + expect(report.unavailable.map(({ source, reason }) => [source, reason])).toEqual([ + ['alerts', "This token can't read it. Add the Notifications Read permission."], + [ + 'security', + "This token can't read it. Add the Account Security Center Insights Read permission." + ] + ]) + }) + + it('is an error when no feed loads', async () => { + const denied = () => + HttpResponse.json(cfError([{ code: 10000, message: 'Authentication error' }]), { + status: 403 + }) + mockFeeds({ alerts: denied, security: denied, workers: denied }) + + expect((await runAttentionTool(singleAccount, {}, NOW)).isError).toBe(true) + }) + + it('says nothing needs attention when every feed is empty', async () => { + mockFeeds({ + alerts: () => HttpResponse.json(cfSuccess([])), + security: () => HttpResponse.json(cfSuccess({ issues: [] })), + workers: () => HttpResponse.json(cfSuccess([])) + }) + const result = await runAttentionTool(singleAccount, {}, NOW) + + expect(reportOf(result).items).toEqual([]) + expect(result.content[0]).toMatchObject({ + text: expect.stringContaining('Nothing needs attention') + }) + }) + + it('treats an empty alert history as no alerts', async () => { + mockFeeds({ alerts: () => HttpResponse.json(cfSuccess(null)) }) + const report = reportOf(await runAttentionTool(singleAccount, {}, NOW)) + + expect(report.unavailable).toEqual([]) + expect(report.items.some((item) => item.source === 'alerts')).toBe(false) + }) + + it('asks for an account when the connection reaches several', async () => { + const result = await runAttentionTool(multiAccount, {}, NOW) + + expect(result.isError).toBe(true) + expect(result).not.toHaveProperty('structuredContent') + }) + + it('rejects an account the connection cannot reach before calling any feed', async () => { + const result = await runAttentionTool(multiAccount, { account_id: 'f'.repeat(32) }, NOW) + + expect(result.isError).toBe(true) + expect(result.content[0]).toMatchObject({ + text: expect.stringContaining( + `account_id ${'f'.repeat(32)} is not one of this session's accounts` + ) + }) + }) + + it('uses the account the caller picks', async () => { + server.use( + http.get(`${API_BASE}/accounts/${OTHER_ACCOUNT_ID}/*`, () => HttpResponse.json(cfSuccess([]))) + ) + const report = reportOf( + await runAttentionTool(multiAccount, { account_id: OTHER_ACCOUNT_ID }, NOW) + ) + + expect(report.accountId).toBe(OTHER_ACCOUNT_ID) + }) +}) + +describe('show_attention over MCP', () => { + beforeEach(async () => { + await seedSpec({}) + }) + + afterEach(async () => { + await clearKv(env.OAUTH_KV) + await clearSpec() + }) + + it('returns the report from tools/call', async () => { + mockIdentityProbe({ + user: { id: 'user-1', email: 'a@example.com' }, + accounts: [{ id: ACCOUNT_ID, name: 'Alice Co' }] + }) + mockFeeds() + const response = await exports.default.fetch( + modernMcpRequest('cfut_attention', 'tools/call', { name: 'show_attention', arguments: {} }) + ) + const body = await parseMcpResult(response) + const report = AttentionReportSchema.parse(body.result?.structuredContent) + + expect(report.items).toHaveLength(3) + }) +}) diff --git a/tests/auth/cimd.test.ts b/tests/auth/cimd.test.ts index 925f669..be6ff98 100644 --- a/tests/auth/cimd.test.ts +++ b/tests/auth/cimd.test.ts @@ -163,7 +163,8 @@ describe('Client ID Metadata Documents', () => { 'docs', 'search', 'execute', - 'whoami' + 'whoami', + 'show_attention' ]) expect(metadataFetches).toBeGreaterThan(0) diff --git a/tests/auth/oauth-routes.test.ts b/tests/auth/oauth-routes.test.ts index c171915..2bf0031 100644 --- a/tests/auth/oauth-routes.test.ts +++ b/tests/auth/oauth-routes.test.ts @@ -897,7 +897,8 @@ describe('GET /oauth/callback', () => { 'docs', 'search', 'execute', - 'whoami' + 'whoami', + 'show_attention' ]) expect((await env.OAUTH_KV.list({ prefix: 'client:' })).keys).toHaveLength(1) }) diff --git a/tests/auth/token-compatibility.test.ts b/tests/auth/token-compatibility.test.ts index 84cc22e..2d3b2e4 100644 --- a/tests/auth/token-compatibility.test.ts +++ b/tests/auth/token-compatibility.test.ts @@ -211,7 +211,7 @@ describe.each(LEGACY_DEPLOYMENTS)( // The access token issued before the upgrade is accepted as it is. expect(await listTools(legacy.access_token)).toEqual({ status: 200, - tools: ['docs', 'search', 'execute', 'whoami'] + tools: ['docs', 'search', 'execute', 'whoami', 'show_attention'] }) // The refresh token is too, and the upstream Cloudflare grant rotates with it. @@ -226,7 +226,7 @@ describe.each(LEGACY_DEPLOYMENTS)( expect(upstreamRefreshes()).toEqual(['upstream-refresh-1']) expect(await listTools(tokens.access_token)).toEqual({ status: 200, - tools: ['docs', 'search', 'execute', 'whoami'] + tools: ['docs', 'search', 'execute', 'whoami', 'show_attention'] }) // The grant is rewritten in the current format (with KV key metadata) and keeps its resource. diff --git a/tests/mcp-client.test.ts b/tests/mcp-client.test.ts index e70c644..f2758bd 100644 --- a/tests/mcp-client.test.ts +++ b/tests/mcp-client.test.ts @@ -64,7 +64,13 @@ describe('automatic protocol negotiation', () => { expect(client.getDiscoverResult()?.supportedVersions).toEqual([MODERN_MCP_VERSION]) const listed = await client.listTools() - expect(listed.tools.map((tool) => tool.name)).toEqual(['docs', 'search', 'execute', 'whoami']) + expect(listed.tools.map((tool) => tool.name)).toEqual([ + 'docs', + 'search', + 'execute', + 'whoami', + 'show_attention' + ]) const called = await client.callTool({ name: 'search', diff --git a/tests/mcp-modern.test.ts b/tests/mcp-modern.test.ts index 912b44d..d4adcce 100644 --- a/tests/mcp-modern.test.ts +++ b/tests/mcp-modern.test.ts @@ -163,7 +163,8 @@ describe('MCP 2026-07-28 stateless handler', () => { 'docs', 'search', 'execute', - 'whoami' + 'whoami', + 'show_attention' ]) }) @@ -283,11 +284,13 @@ describe('MCP 2026-07-28 stateless handler', () => { 'docs', 'search', 'execute', - 'whoami' + 'whoami', + 'show_attention' ]) expect(endpoints.result?.tools?.map((tool) => tool.name)).toEqual([ 'docs', 'whoami', + 'show_attention', 'get_accounts_workers_scripts' ]) expect(codemodeResponse.headers.get('mcp-session-id')).toBeNull() @@ -325,7 +328,8 @@ describe('MCP 2026-07-28 stateless handler', () => { 'docs', 'search', 'execute', - 'whoami' + 'whoami', + 'show_attention' ]) }) diff --git a/tests/non-codemode.test.ts b/tests/non-codemode.test.ts index 4e150a2..2a603c8 100644 --- a/tests/non-codemode.test.ts +++ b/tests/non-codemode.test.ts @@ -272,7 +272,8 @@ describe('createServer with codemode=false', () => { expect(Object.keys((server as any)._registeredTools)).toEqual([]) const tools = await listTools(server) - expect(tools).toHaveLength(3_002) // docs + whoami + 3,000 endpoint tools + const fixedTools = ['docs', 'whoami', 'show_attention'] + expect(tools).toHaveLength(fixedTools.length + 3_000) }) it('registers one tool per endpoint when codemode=false', async () => { From bce5a398afce283309835cc7d8b12d4bcc4fceef Mon Sep 17 00:00:00 2001 From: Jordan Coeyman Date: Wed, 14 Oct 2026 09:53:18 -0400 Subject: [PATCH 2/2] fix(apps): show readable Security Center text and resolve its dashboard links Security Center returns dashboard translation keys in resolve_text and relative paths such as /:account_id/turnstile in resolve_link. Fall back to the insight class for keys, and resolve relative paths on dash.cloudflare.com. --- src/apps/attention/sources.ts | 30 ++++++++++++++++++------ tests/apps/attention.test.ts | 44 +++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/apps/attention/sources.ts b/src/apps/attention/sources.ts index 63bf24d..92775ee 100644 --- a/src/apps/attention/sources.ts +++ b/src/apps/attention/sources.ts @@ -132,16 +132,32 @@ const SECURITY_SEVERITY: Record<'Low' | 'Moderate' | 'Critical', Severity> = { Low: 'info' } -function dashboardLink(value: string | undefined): string | undefined { - if (!value || !URL.canParse(value)) return undefined - const url = new URL(value) +const DASHBOARD_ORIGIN = 'https://dash.cloudflare.com' + +function dashboardLink(value: string | undefined, accountId: string): string | undefined { + if (!value) return undefined + const resolved = value.startsWith('/') + ? `${DASHBOARD_ORIGIN}${value.replaceAll(':account_id', encodeURIComponent(accountId))}` + : value + if (!URL.canParse(resolved)) return undefined + const url = new URL(resolved) return url.protocol === 'https:' && url.hostname === 'dash.cloudflare.com' ? url.href : undefined } -function securityItem(insight: z.infer): AttentionItem { +const DASHBOARD_TRANSLATION_KEY = /^[a-z0-9_]+(\.[a-z0-9_]+)+$/ + +function readableText(value: string | undefined): string | undefined { + return value && !DASHBOARD_TRANSLATION_KEY.test(value) ? value : undefined +} + +function securityItem( + insight: z.infer, + accountId: string +): AttentionItem { const kind = (insight.issue_type ?? 'security insight').replaceAll('_', ' ') const subject = insight.subject ?? 'this account' - const detail = insight.resolve_text || insight.issue_class?.replaceAll('_', ' ') || kind + const detail = + readableText(insight.resolve_text) || insight.issue_class?.replaceAll('_', ' ') || kind return { id: `security:${insight.id}`, source: 'security', @@ -149,7 +165,7 @@ function securityItem(insight: z.infer): Attention title: `${kind} on ${subject}`, detail, observedAt: insight.timestamp, - link: dashboardLink(insight.resolve_link), + link: dashboardLink(insight.resolve_link, accountId), followUp: `Cloudflare Security Center flagged ${kind} on ${subject}: ${detail}. Explain the risk and walk me through fixing it.` } } @@ -193,7 +209,7 @@ async function alertItems(request: FeedRequest): Promise { async function securityItems(request: FeedRequest): Promise { const query = new URLSearchParams({ dismissed: 'false', per_page: String(PAGE_SIZE) }) const result = SecurityResultSchema.parse(await fetchResult(request, FEED_PATH.security, query)) - return result.issues.map(securityItem) + return result.issues.map((insight) => securityItem(insight, request.accountId)) } async function workersItems(request: FeedRequest): Promise { diff --git a/tests/apps/attention.test.ts b/tests/apps/attention.test.ts index 723d6b0..2b95575 100644 --- a/tests/apps/attention.test.ts +++ b/tests/apps/attention.test.ts @@ -148,6 +148,50 @@ describe('show_attention result', () => { ).toEqual([undefined, undefined]) }) + it('resolves the relative dashboard links Security Center returns', async () => { + mockFeeds({ + security: () => + HttpResponse.json( + cfSuccess({ + issues: [ + { ...INSIGHT, id: 'a', resolve_link: '/:account_id/turnstile' }, + { ...INSIGHT, id: 'b', resolve_link: '/profile/authentication' } + ] + }) + ) + }) + const report = reportOf(await runAttentionTool(singleAccount, {}, NOW)) + + expect( + report.items.filter((item) => item.source === 'security').map((item) => item.link) + ).toEqual([ + `https://dash.cloudflare.com/${ACCOUNT_ID}/turnstile`, + 'https://dash.cloudflare.com/profile/authentication' + ]) + }) + + it('replaces untranslated dashboard keys with the insight class', async () => { + mockFeeds({ + security: () => + HttpResponse.json( + cfSuccess({ + issues: [ + { + ...INSIGHT, + issue_class: 'mfa_not_enabled', + resolve_text: 'security_center.checks.mfa.resolution_button' + } + ] + }) + ) + }) + const report = reportOf(await runAttentionTool(singleAccount, {}, NOW)) + const insight = report.items.find((item) => item.source === 'security') + + expect(insight?.detail).toBe('mfa not enabled') + expect(insight?.followUp).not.toContain('security_center.') + }) + it('writes the Workers issue count and time for people to read', async () => { mockFeeds({ workers: () =>