Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,15 @@ 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';
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];
Comment thread
kathmbeck marked this conversation as resolved.

const toSelectorSchema = (domainTool: DomainTool<any>) => {
return z.object({
Expand Down
176 changes: 176 additions & 0 deletions src/tools/observability-tools/api-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
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. Returns undefined if invalid.
*/
export const parseFilters = (raw?: string): QueryFilter[] | undefined => {
if (!raw) return undefined;
try {
return JSON.parse(raw) as QueryFilter[];
} catch {
return undefined;
}
};

/**
* Parse a JSON string of sort specs into an array. Returns undefined if invalid.
*/
export const parseSortBy = (raw?: string): SortBy[] | undefined => {
if (!raw) return undefined;
try {
return JSON.parse(raw) as SortBy[];
} catch {
return undefined;
}
Comment thread
kathmbeck marked this conversation as resolved.
};

// ---- API Methods ----

const doPost = async (path: string, params: URLSearchParams, body: RequestPayload, incomingRequest?: Request): Promise<any> => {
const url = `${path}${buildQueryString(params)}`;

const response = await authenticatedFetch(url, {
method: 'POST',
body: JSON.stringify(body),
}, incomingRequest);
Comment thread
kathmbeck marked this conversation as resolved.

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<any> => {
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/${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/${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/${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/${siteID}/observability/query/lists`, timeWindowParams(fromTS, toTS), buildPayload(query), incomingRequest);
};

export const getRequestDetail = async (
siteID: string, requestID: string, fromTS?: number, incomingRequest?: Request
) => {
const params = new URLSearchParams();
if (fromTS && fromTS > 0) {
params.set('from_ts', String(fromTS));
}
return doGet(`/api/v1/sites/${siteID}/observability/requests/${requestID}`, params, incomingRequest);
};

export const getAlerts = async (
siteID: string, fromTS: number, toTS: number,
severity?: string, incomingRequest?: Request
) => {
const params = timeWindowParams(fromTS, toTS);
if (severity) {
params.set('severity', severity);
}
return doGet(`/api/v1/sites/${siteID}/observability/alerts`, params, incomingRequest);
};
Comment thread
kathmbeck marked this conversation as resolved.
Outdated
174 changes: 174 additions & 0 deletions src/tools/observability-tools/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { z } from 'zod';
import type { DomainTool } from '../types.js';
import {
getCounts,
getTopK,
getTimeSeries,
getLists,
getRequestDetail,
getAlerts,
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<typeof getCountsSchema> = {
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<typeof getTopKSchema> = {
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<typeof getTimeSeriesSchema> = {
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<typeof getRequestLogsSchema> = {
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().optional().describe('Optional start timestamp in milliseconds for time context'),
});
Comment thread
kathmbeck marked this conversation as resolved.

const getRequestDetailDomainTool: DomainTool<typeof getRequestDetailSchema> = {
domain: 'observability',
operation: 'get-request-detail',
inputSchema: getRequestDetailSchema,
toolAnnotations: { readOnlyHint: true },
cb: async ({ siteId, request_id, from_ts }, { request }) => {
const result = await getRequestDetail(siteId, request_id, from_ts, request);
return JSON.stringify(result);
},
};

// ---- Tool: get-alerts ----

const getAlertsSchema = 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'),
severity: z.string().optional().describe("Comma-separated severity filter (e.g. 'critical,high')"),
});

const getAlertsDomainTool: DomainTool<typeof getAlertsSchema> = {
domain: 'observability',
operation: 'get-alerts',
inputSchema: getAlertsSchema,
toolAnnotations: { readOnlyHint: true },
cb: async ({ siteId, from_ts, to_ts, severity }, { request }) => {
const result = await getAlerts(siteId, from_ts, to_ts, severity, request);
return JSON.stringify(result);
Comment thread
kathmbeck marked this conversation as resolved.
},
};

// ---- Export all tools ----

export const observabilityDomainTools = [
getCountsDomainTool,
getTopKDomainTool,
getTimeSeriesDomainTool,
getRequestLogsDomainTool,
getRequestDetailDomainTool,
getAlertsDomainTool,
];
2 changes: 1 addition & 1 deletion src/tools/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';