auditlogs.tools.ts•10.5 kB
import { z } from 'zod'
import { fetchCloudflareApi } from '@repo/mcp-common/src/cloudflare-api'
import { getProps } from '@repo/mcp-common/src/get-props'
import type { AuditlogMCP } from '../auditlogs.app'
export const actionResults = z.enum(['success', 'failure', ''])
export const actionTypes = z.enum(['create', 'delete', 'view', 'update', 'login'])
export const actorContexts = z.enum(['api_key', 'api_token', 'dash', 'oauth', 'origin_ca_key'])
export const actorTypes = z.enum(['cloudflare_admin', 'account', 'user', 'system'])
export const resourceScopes = z.enum(['memberships', 'accounts', 'user', 'zones'])
export const sortDirections = z.enum(['desc', 'asc'])
export const auditLogsQuerySchema = z.object({
account_name: z.string().optional().describe('The account name to filter audit logs by.'),
action_result: actionResults.optional().describe('Whether the action was a success or failure.'),
action_type: actionTypes.optional().describe('The type of action that was performed.'),
actor_context: actorContexts.optional().describe('The context in which the actor was operating.'),
actor_email: z
.string()
.email()
.optional()
.describe('The email of the actor who triggered the event.'),
actor_id: z.string().optional().describe('The unique identifier of the actor.'),
actor_ip_address: z.string().optional().describe('The IP address of the actor.'),
actor_token_id: z.string().optional().describe('The API token ID used by the actor.'),
actor_token_name: z.string().optional().describe('The name of the API token used by the actor.'),
actor_type: actorTypes.optional().describe('The type of actor (e.g., user, token).'),
audit_log_id: z.string().optional().describe('The unique identifier of the audit log entry.'),
raw_cf_ray_id: z
.string()
.optional()
.describe('The Cloudflare Ray ID associated with the request.'),
raw_method: z
.string()
.optional()
.describe('The HTTP method used in the request (e.g., GET, POST).'),
raw_status_code: z.number().optional().describe('The HTTP status code returned by the request.'),
raw_uri: z.string().optional().describe('The URI accessed in the request.'),
resource_id: z.string().optional().describe('The unique identifier of the resource affected.'),
resource_product: z
.string()
.optional()
.describe('The Cloudflare product related to the resource.'),
resource_type: z.string().optional().describe('The type of resource affected.'),
resource_scope: resourceScopes
.optional()
.describe('The scope of the resource (e.g., account, zone).'),
zone_id: z.string().optional().describe('The ID of the zone associated with the log.'),
zone_name: z.string().optional().describe('The name of the zone associated with the log.'),
since: z
.string()
.describe(
'The start of the time slice to look at. Can be YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss.sssZ'
)
.regex(
/^(\d{4}-\d{2}-\d{2}|(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z))$/,
'Date must be in YYYY-MM-DD or ISO 8601 format with milliseconds (e.g., YYYY-MM-DDTHH:mm:ss.sssZ)'
),
before: z
.string()
.describe('The end of the time slice to look at. Can be YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss.sssZ')
.regex(
/^(\d{4}-\d{2}-\d{2}|(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z))$/,
'Date must be in YYYY-MM-DD or ISO 8601 format with milliseconds (e.g., YYYY-MM-DDTHH:mm:ss.sssZ)'
),
direction: sortDirections.optional().describe('The sort direction of the logs (asc or desc).'),
limit: z
.number()
.min(1)
.max(1000)
.optional()
.describe('The number of results to return (max 1000).'),
cursor: z.string().optional().describe('Pagination cursor for fetching the next set of results.'),
})
// Core schema for one audit log entry
const auditLogEntrySchema = z.object({
id: z.string().max(36).describe('Unique identifier for the audit log entry'),
account: z
.object({
id: z.string().describe('The ID of the account'),
name: z.string().describe('The name of the account'),
})
.describe('Account information associated with the audit log'),
action: z
.object({
description: z.string().optional().describe('Description of the action taken'),
result: actionResults.describe('Result of the action'),
time: z.string().datetime().describe('Timestamp of when the action occurred'),
type: actionTypes.describe('Type of action performed'),
})
.describe('Details of the action performed in the audit log'),
actor: z
.object({
context: actorContexts.optional().describe('Context associated with the actor'),
email: z.string().email().optional().describe('Email of the actor'),
id: z.string().optional().describe('ID of the actor'),
ip_address: z.string().optional().describe('IP address of the actor'),
type: actorTypes.optional().describe('Type of the actor'),
token_id: z.string().optional().describe('Token ID if available'),
token_name: z.string().optional().describe('Token name if available'),
})
.optional()
.describe('Information about the actor who performed the action'),
resource: z
.object({
id: z.string().optional().describe('Resource ID involved in the action'),
product: z.string().optional().describe('Product related to the action'),
request: z.record(z.unknown()).optional().describe('Request details of the action'),
response: z.record(z.unknown()).optional().describe('Response details of the action'),
scope: z
.union([z.string(), z.object({})])
.optional()
.describe('Scope of the resource, e.g., "accounts"'),
type: z.string().optional().describe('Type of resource involved'),
})
.optional()
.describe('Details of the resource involved in the action'),
raw: z
.object({
cf_ray_id: z.string().optional().describe('Cloudflare Ray ID associated with the request'),
method: z.string().optional().describe('HTTP method used for the request'),
status_code: z.number().optional().describe('HTTP status code of the response'),
uri: z.string().optional().describe('URI of the request'),
user_agent: z.string().optional().describe('User-Agent header of the request'),
})
.optional()
.describe('Raw data related to the request made during the action'),
zone: z
.object({
id: z.string().optional().describe('ID of the zone involved in the action'),
name: z.string().optional().describe('Name of the zone involved in the action'),
})
.optional()
.describe('Zone information related to the action'),
})
// Wrapper schema for response
export const resultInfoSchema = z.object({
count: z.number(),
cursor: z.string().optional(),
})
export const auditLogsResponseSchema = z.object({
success: z.literal(true),
errors: z.array(z.object({ message: z.string() })).optional(),
result: z.array(auditLogEntrySchema).optional(),
result_info: resultInfoSchema,
})
export const trimmedAuditLog = z.object({
description: z.string().optional().describe('Description of the action taken'),
time: z.string().datetime().describe('Timestamp of when the action occurred'),
product: z.string().optional().describe('Product related to the action'),
type: z.string().optional().describe('Type of resource involved'),
actor_email: z.string().email().optional().describe('Email of the actor'),
actor_token_name: z.string().optional().describe('Token name if available'),
})
export const trimmedAuditLogsResponseSchema = z.object({
logs: z.array(trimmedAuditLog),
result_info: resultInfoSchema,
})
export type AuditLogOptions = z.infer<typeof auditLogsQuerySchema>
export async function handleGetAuditLogs(
accountId: string,
apiToken: string,
options: AuditLogOptions
): Promise<z.infer<typeof trimmedAuditLogsResponseSchema>> {
// Default to just getting the first 10
if (!options.limit) {
options.limit = 10
}
// Validate and parse query parameters using Zod
const validatedParams = auditLogsQuerySchema.parse(options)
// Build query string from validated parameters
const queryParams = new URLSearchParams()
for (const [key, value] of Object.entries(validatedParams)) {
if (value !== undefined && value !== null) {
queryParams.append(key, String(value)) // Ensure everything is converted to string
}
}
// Call the Public API
const data = await fetchCloudflareApi({
endpoint: `/logs/audit?${queryParams.toString()}`,
accountId,
apiToken,
responseSchema: auditLogsResponseSchema,
options: {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'portal-version': '2',
},
},
})
// Trim down the results to relevant information
const results = (data.result || []).map((res) => {
return {
description: res.action.description || '',
time: res.action.time,
actor_email: res.actor?.email,
actor_token_name: res.actor?.token_name,
product: res.resource?.product,
type: res.resource?.type,
} as z.infer<typeof trimmedAuditLog>
})
return {
logs: results,
result_info: data.result_info,
}
}
/**
* Registers the audit log tool with the MCP server
* @param server The MCP server instance
* @param accountId Cloudflare account ID
* @param apiToken Cloudflare API token
*/
export function registerAuditLogTools(agent: AuditlogMCP) {
// Register the audit log tool by account
agent.server.tool(
'auditlogs_by_account_id',
`Find all audit logs (a list of who made what change when) for a Cloudflare Account by ID.
This can be used to query activity on your Cloudflare account at a particular time.
Since and before are required to look at a slice of time and are dates with or without a time up to millisecond precision e.g YYYY-MM-DDTHH:mm:ss.sssZ.
There can be more than one page of results and they can be paginated using the returned cursor`,
auditLogsQuerySchema.shape,
async (params) => {
const accountId = await agent.getActiveAccountId()
if (!accountId) {
return {
content: [
{
type: 'text',
text: 'No currently active accountId. Try listing your accounts (accounts_list) and then setting an active account (set_active_account)',
},
],
}
}
try {
const props = getProps(agent)
const result = await handleGetAuditLogs(accountId, props.accessToken, params)
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
}
} catch (error) {
return {
content: [
{
type: 'text',
text: JSON.stringify({
error: `Error reading audit logs: ${error instanceof Error && error.message}`,
}),
},
],
}
}
}
)
}