diff --git a/src/tools/index.ts b/src/tools/index.ts index ba8a818..ec30d72 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -28,6 +28,7 @@ import { deployDomainTools } from './deploy-tools/index.js'; import { teamDomainTools } from './team-tools/index.js'; import { projectDomainTools } from './project-tools/index.js'; import { extensionDomainTools } from './extension-tools/index.js'; +import { observabilityDomainTools } from './observability-tools/index.js'; import { checkCompatibility } from '../utils/compatibility.js'; import { getNetlifyAccessToken, NetlifyUnauthError } from '../utils/api-networking.js'; import { appendToLog } from '../utils/logging.js'; @@ -35,7 +36,7 @@ import { categorizeToolsByReadWrite } from './tool-utils.js'; import { z } from 'zod'; import type { DomainTool } from './types.js'; -const listOfDomainTools = [userDomainTools, deployDomainTools, teamDomainTools, projectDomainTools, extensionDomainTools]; +const listOfDomainTools = [userDomainTools, deployDomainTools, teamDomainTools, projectDomainTools, extensionDomainTools, observabilityDomainTools]; const toSelectorSchema = (domainTool: DomainTool) => { return z.object({ diff --git a/src/tools/observability-tools/api-client.ts b/src/tools/observability-tools/api-client.ts new file mode 100644 index 0000000..b8f89a4 --- /dev/null +++ b/src/tools/observability-tools/api-client.ts @@ -0,0 +1,161 @@ +import { authenticatedFetch } from '../../utils/api-networking.js'; + +// All observability endpoints live under the standard Netlify API at +// /api/v1/sites/{siteID}/observability/... +// authenticatedFetch already resolves relative paths against https://api.netlify.com. + +// ---- Types ---- + +interface QueryFilter { + field: string; + op: string; + value: string | number; +} + +interface SortBy { + field: string; + order: 'ASC' | 'DESC'; +} + +interface RequestQuery { + name: string; + filters?: QueryFilter[]; + sort_by?: SortBy[]; + limit?: number; + offset?: number; + page?: number; + per_page?: number; +} + +interface RequestPayload { + data: Array<{ + attributes: { + queries: RequestQuery[]; + }; + }>; +} + +// ---- Helpers ---- + +const buildPayload = (query: RequestQuery): RequestPayload => ({ + data: [{ + attributes: { + queries: [query], + }, + }], +}); + +const buildQueryString = (params: URLSearchParams): string => { + const qs = params.toString(); + return qs ? `?${qs}` : ''; +}; + +const timeWindowParams = (fromTS: number, toTS: number): URLSearchParams => { + const params = new URLSearchParams(); + params.set('from_ts', String(fromTS)); + params.set('to_ts', String(toTS)); + return params; +}; + +/** + * Parse a JSON string of filters into an array. Throws if the JSON is invalid. + */ +export const parseFilters = (raw?: string): QueryFilter[] | undefined => { + if (!raw) return undefined; + try { + return JSON.parse(raw) as QueryFilter[]; + } catch { + throw new Error(`Invalid filters: expected a JSON array, got ${raw}`); + } +}; + +/** + * Parse a JSON string of sort specs into an array. Throws if the JSON is invalid. + */ +export const parseSortBy = (raw?: string): SortBy[] | undefined => { + if (!raw) return undefined; + try { + return JSON.parse(raw) as SortBy[]; + } catch { + throw new Error(`Invalid sort_by: expected a JSON array, got ${raw}`); + } +}; + +// ---- API Methods ---- + +const doPost = async (path: string, params: URLSearchParams, body: RequestPayload, incomingRequest?: Request): Promise => { + const url = `${path}${buildQueryString(params)}`; + + const response = await authenticatedFetch(url, { + method: 'POST', + body: JSON.stringify(body), + }, incomingRequest); + + if (response.status === 204) { + return { data: [], meta: {} }; + } + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Observability API error (HTTP ${response.status}): ${text}`); + } + + return response.json(); +}; + +const doGet = async (path: string, params: URLSearchParams, incomingRequest?: Request): Promise => { + const url = `${path}${buildQueryString(params)}`; + + const response = await authenticatedFetch(url, {}, incomingRequest); + + if (response.status === 204) { + return { data: [], meta: {} }; + } + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Observability API error (HTTP ${response.status}): ${text}`); + } + + return response.json(); +}; + +// ---- Exported Client Functions ---- + +export const getCounts = async ( + siteID: string, fromTS: number, toTS: number, + query: RequestQuery, incomingRequest?: Request +) => { + return doPost(`/api/v1/sites/${encodeURIComponent(siteID)}/observability/query/counts`, timeWindowParams(fromTS, toTS), buildPayload(query), incomingRequest); +}; + +export const getTopK = async ( + siteID: string, fromTS: number, toTS: number, + query: RequestQuery, incomingRequest?: Request +) => { + return doPost(`/api/v1/sites/${encodeURIComponent(siteID)}/observability/query/topk`, timeWindowParams(fromTS, toTS), buildPayload(query), incomingRequest); +}; + +export const getTimeSeries = async ( + siteID: string, fromTS: number, toTS: number, + interval: number | undefined, query: RequestQuery, incomingRequest?: Request +) => { + const params = timeWindowParams(fromTS, toTS); + if (interval && interval > 0) { + params.set('interval', String(interval)); + } + return doPost(`/api/v1/sites/${encodeURIComponent(siteID)}/observability/query/timeseries`, params, buildPayload(query), incomingRequest); +}; + +export const getLists = async ( + siteID: string, fromTS: number, toTS: number, + query: RequestQuery, incomingRequest?: Request +) => { + return doPost(`/api/v1/sites/${encodeURIComponent(siteID)}/observability/query/lists`, timeWindowParams(fromTS, toTS), buildPayload(query), incomingRequest); +}; + +export const getRequestDetail = async ( + siteID: string, requestID: string, fromTS: number, toTS: number, incomingRequest?: Request +) => { + return doGet(`/api/v1/sites/${encodeURIComponent(siteID)}/observability/requests/${encodeURIComponent(requestID)}`, timeWindowParams(fromTS, toTS), incomingRequest); +}; diff --git a/src/tools/observability-tools/index.ts b/src/tools/observability-tools/index.ts new file mode 100644 index 0000000..3dd3010 --- /dev/null +++ b/src/tools/observability-tools/index.ts @@ -0,0 +1,153 @@ +import { z } from 'zod'; +import type { DomainTool } from '../types.js'; +import { + getCounts, + getTopK, + getTimeSeries, + getLists, + getRequestDetail, + parseFilters, + parseSortBy, +} from './api-client.js'; + +// ---- Shared descriptions ---- + +const filtersDescription = `Optional JSON array of filter objects. Each filter has: "field" (StatusCode, URL, Method, UserAgent, ContentType, BlockReason, CacheStatus, EdgeFunctionName, FunctionName, LogText, RequestId, ClientAddress, ConnectionAddress, Branch), "op" (=, !=, >, <, >=, <=), and "value". Example: [{"field":"StatusCode","op":">=","value":500}]`; + +const sortByDescription = `Optional JSON array of sort objects. Each has "field" (Count, ErrorCount, Bandwidth, P50, P75, P90, P99, Key, FunctionInvocations, EdgeFunctionInvocations) and "order" (ASC or DESC). Default: [{"field":"Count","order":"DESC"}]`; + +// ---- Tool: get-counts ---- + +const getCountsSchema = z.object({ + siteId: z.string().describe('Netlify site ID'), + from_ts: z.number().describe('Start timestamp in milliseconds since Unix epoch'), + to_ts: z.number().describe('End timestamp in milliseconds since Unix epoch'), + query_name: z.string().describe('Type of count: status_codes, content_types, user_agent_categories, methods, block_reasons, cache_status, function_names, edge_function_names'), + filters: z.string().optional().describe(filtersDescription), +}); + +const getCountsDomainTool: DomainTool = { + domain: 'observability', + operation: 'get-counts', + inputSchema: getCountsSchema, + toolAnnotations: { readOnlyHint: true }, + cb: async ({ siteId, from_ts, to_ts, query_name, filters }, { request }) => { + const result = await getCounts(siteId, from_ts, to_ts, { + name: query_name, + filters: parseFilters(filters), + }, request); + return JSON.stringify(result); + }, +}; + +// ---- Tool: get-topk ---- + +const getTopKSchema = z.object({ + siteId: z.string().describe('Netlify site ID'), + from_ts: z.number().describe('Start timestamp in milliseconds since Unix epoch'), + to_ts: z.number().describe('End timestamp in milliseconds since Unix epoch'), + query_name: z.string().describe('Breakdown type: user_agents, user_agent_categories, urls, urls_with_query, status_codes, referrers, functions, edge_functions, content_types, client_address, client_countries, connection_address'), + limit: z.number().optional().describe('Max items to return (default 10)'), + offset: z.number().optional().describe('Pagination offset (default 0)'), + filters: z.string().optional().describe(filtersDescription), + sort_by: z.string().optional().describe(sortByDescription), +}); + +const getTopKDomainTool: DomainTool = { + domain: 'observability', + operation: 'get-topk', + inputSchema: getTopKSchema, + toolAnnotations: { readOnlyHint: true }, + cb: async ({ siteId, from_ts, to_ts, query_name, limit, offset, filters, sort_by }, { request }) => { + const result = await getTopK(siteId, from_ts, to_ts, { + name: query_name, + limit: limit || 10, + offset: offset || 0, + filters: parseFilters(filters), + sort_by: parseSortBy(sort_by), + }, request); + return JSON.stringify(result); + }, +}; + +// ---- Tool: get-timeseries ---- + +const getTimeSeriesSchema = z.object({ + siteId: z.string().describe('Netlify site ID'), + from_ts: z.number().describe('Start timestamp in milliseconds since Unix epoch'), + to_ts: z.number().describe('End timestamp in milliseconds since Unix epoch'), + query_name: z.string().describe('Metric type: edge_requests_count, edge_requests_duration_percentiles, edge_requests_bandwidth'), + interval: z.number().optional().describe('Bucket interval in milliseconds. If not set, the API picks a default based on the time window.'), + filters: z.string().optional().describe(filtersDescription), +}); + +const getTimeSeriesDomainTool: DomainTool = { + domain: 'observability', + operation: 'get-timeseries', + inputSchema: getTimeSeriesSchema, + toolAnnotations: { readOnlyHint: true }, + cb: async ({ siteId, from_ts, to_ts, query_name, interval, filters }, { request }) => { + const result = await getTimeSeries(siteId, from_ts, to_ts, interval, { + name: query_name, + filters: parseFilters(filters), + }, request); + return JSON.stringify(result); + }, +}; + +// ---- Tool: get-request-logs ---- + +const getRequestLogsSchema = z.object({ + siteId: z.string().describe('Netlify site ID'), + from_ts: z.number().describe('Start timestamp in milliseconds since Unix epoch'), + to_ts: z.number().describe('End timestamp in milliseconds since Unix epoch'), + page: z.number().optional().describe('Page number (1-based, default 1)'), + per_page: z.number().optional().describe('Items per page (default 20)'), + filters: z.string().optional().describe(filtersDescription), +}); + +const getRequestLogsDomainTool: DomainTool = { + domain: 'observability', + operation: 'get-request-logs', + inputSchema: getRequestLogsSchema, + toolAnnotations: { readOnlyHint: true }, + cb: async ({ siteId, from_ts, to_ts, page, per_page, filters }, { request }) => { + const result = await getLists(siteId, from_ts, to_ts, { + name: 'edge_requests_logs', + page: page || 1, + per_page: per_page || 20, + filters: parseFilters(filters), + }, request); + return JSON.stringify(result); + }, +}; + +// ---- Tool: get-request-detail ---- + +const getRequestDetailSchema = z.object({ + siteId: z.string().describe('Netlify site ID'), + request_id: z.string().describe('Request ID (ULID format)'), + from_ts: z.number().describe('Start timestamp in milliseconds since Unix epoch. Must bracket the request (e.g. a minute before it).'), + to_ts: z.number().describe('End timestamp in milliseconds since Unix epoch. Must bracket the request (e.g. a minute after it).'), +}); + +const getRequestDetailDomainTool: DomainTool = { + domain: 'observability', + operation: 'get-request-detail', + inputSchema: getRequestDetailSchema, + toolAnnotations: { readOnlyHint: true }, + cb: async ({ siteId, request_id, from_ts, to_ts }, { request }) => { + const result = await getRequestDetail(siteId, request_id, from_ts, to_ts, request); + return JSON.stringify(result); + }, +}; + +// ---- Export all tools ---- + +export const observabilityDomainTools = [ + getCountsDomainTool, + getTopKDomainTool, + getTimeSeriesDomainTool, + getRequestLogsDomainTool, + getRequestDetailDomainTool, +]; diff --git a/src/tools/types.ts b/src/tools/types.ts index 9d17ce8..83a6105 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -16,4 +16,4 @@ export interface MCPEnvContext { isRemoteMCP?: boolean; } -export type ToolDomain = 'project' | 'team' | 'user' | 'deploy' | 'extension'; +export type ToolDomain = 'project' | 'team' | 'user' | 'deploy' | 'extension' | 'observability';