google-cloud-mcp
Version:
Model Context Protocol server for Google Cloud services
409 lines • 19.8 kB
JavaScript
/**
* Google Cloud Monitoring integration for MCP
*/
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import monitoring from '@google-cloud/monitoring';
const { MetricServiceClient } = monitoring;
import { z } from 'zod';
import { getProjectId } from '../utils/auth.js';
import { GcpMcpError } from '../utils/error.js';
/**
* Initialises the Google Cloud Monitoring client
*
* @returns A configured Monitoring client
*/
function getMonitoringClient() {
return new MetricServiceClient({
projectId: process.env.GOOGLE_CLOUD_PROJECT
});
}
/**
* Formats a time series data point for display
*
* @param timeSeries The time series data to format
* @returns A formatted string representation of the time series data
*/
function formatTimeSeriesData(timeSeries) {
if (!timeSeries || timeSeries.length === 0) {
return 'No time series data found.';
}
let result = '';
for (const series of timeSeries) {
// Format metric information
const metricType = series.metric.type;
const metricLabels = series.metric.labels
? Object.entries(series.metric.labels)
.map(([k, v]) => `${k}=${v}`)
.join(', ')
: '';
const resourceType = series.resource.type;
const resourceLabels = Object.entries(series.resource.labels)
.map(([k, v]) => `${k}=${v}`)
.join(', ');
result += `## Metric: ${metricType}\n`;
result += `- Resource: ${resourceType}(${resourceLabels})\n`;
if (metricLabels) {
result += `- Labels: ${metricLabels}\n`;
}
result += `- Kind: ${series.metricKind}, Type: ${series.valueType}\n\n`;
// Format data points
result += '| Timestamp | Value |\n';
result += '|-----------|-------|\n';
for (const point of series.points) {
const timestamp = new Date(point.interval.endTime).toISOString();
// Extract the value based on valueType
let value;
if (point.value.boolValue !== undefined) {
value = String(point.value.boolValue);
}
else if (point.value.int64Value !== undefined) {
value = point.value.int64Value;
}
else if (point.value.doubleValue !== undefined) {
value = point.value.doubleValue.toFixed(6);
}
else if (point.value.stringValue !== undefined) {
value = point.value.stringValue;
}
else if (point.value.distributionValue) {
value = 'Distribution';
}
else {
value = 'N/A';
}
result += `| ${timestamp} | ${value} |\n`;
}
result += '\n---\n\n';
}
return result;
}
/**
* Registers Google Cloud Monitoring resources with the MCP server
*
* @param server The MCP server instance
*/
export function registerMonitoringResources(server) {
// Register a resource for recent metrics
server.resource('recent-metrics', new ResourceTemplate('gcp-monitoring://{projectId}/recent', { list: undefined }), async (uri, { projectId }, context) => {
try {
const actualProjectId = projectId || await getProjectId();
const client = getMonitoringClient();
// Default filter from environment variable or use a common metric
const defaultFilter = process.env.MONITORING_FILTER || 'metric.type="compute.googleapis.com/instance/cpu/utilization"';
// Create time range for the last hour
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const [timeSeries] = await client.listTimeSeries({
name: `projects/${actualProjectId}`,
filter: defaultFilter,
interval: {
startTime: {
seconds: Math.floor(oneHourAgo.getTime() / 1000),
nanos: 0
},
endTime: {
seconds: Math.floor(now.getTime() / 1000),
nanos: 0
}
}
});
if (!timeSeries || timeSeries.length === 0) {
return {
contents: [{
uri: uri.href,
text: `# Recent Metrics for Project: ${actualProjectId}\n\nNo metrics found matching filter: ${defaultFilter}`
}]
};
}
const formattedData = formatTimeSeriesData(timeSeries);
return {
contents: [{
uri: uri.href,
text: `# Recent Metrics for Project: ${actualProjectId}\n\nFilter: ${defaultFilter}\nTime Range: ${oneHourAgo.toISOString()} to ${now.toISOString()}\n\n${formattedData}`
}]
};
}
catch (error) {
console.error('Error in recent-metrics resource:', error);
throw new GcpMcpError(`Failed to retrieve recent metrics: ${error.message}`, error.code || 'UNKNOWN', error.statusCode || 500);
}
});
// Register a resource for metrics with a custom filter
server.resource('filtered-metrics', new ResourceTemplate('gcp-monitoring://{projectId}/filter/{filter}', { list: undefined }), async (uri, { projectId, filter }, context) => {
try {
const actualProjectId = projectId || await getProjectId();
const client = getMonitoringClient();
if (!filter) {
throw new GcpMcpError('Metric filter is required', 'INVALID_ARGUMENT', 400);
}
const decodedFilter = Array.isArray(filter) ? decodeURIComponent(filter[0]) : decodeURIComponent(filter);
// Create time range for the last hour
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const [timeSeries] = await client.listTimeSeries({
name: `projects/${actualProjectId}`,
filter: decodedFilter,
interval: {
startTime: {
seconds: Math.floor(oneHourAgo.getTime() / 1000),
nanos: 0
},
endTime: {
seconds: Math.floor(now.getTime() / 1000),
nanos: 0
}
}
});
if (!timeSeries || timeSeries.length === 0) {
return {
contents: [{
uri: uri.href,
text: `# Filtered Metrics for Project: ${actualProjectId}\n\nNo metrics found matching filter: ${decodedFilter}`
}]
};
}
const formattedData = formatTimeSeriesData(timeSeries);
return {
contents: [{
uri: uri.href,
text: `# Filtered Metrics for Project: ${actualProjectId}\n\nFilter: ${decodedFilter}\nTime Range: ${oneHourAgo.toISOString()} to ${now.toISOString()}\n\n${formattedData}`
}]
};
}
catch (error) {
console.error('Error in filtered-metrics resource:', error);
throw new GcpMcpError(`Failed to retrieve filtered metrics: ${error.message}`, error.code || 'UNKNOWN', error.statusCode || 500);
}
});
}
/**
* Registers Google Cloud Monitoring tools with the MCP server
*
* @param server The MCP server instance
*/
export function registerMonitoringTools(server) {
// Tool to query metrics with a custom filter and time range
server.tool('query-metrics', {
filter: z.string().describe('The filter to apply to metrics'),
startTime: z.string().describe('Start time in ISO format or relative time (e.g., "1h", "2d")'),
endTime: z.string().optional().describe('End time in ISO format (defaults to now)'),
alignmentPeriod: z.string().optional().describe('Alignment period (e.g., "60s", "300s")')
}, async ({ filter, startTime, endTime, alignmentPeriod }, context) => {
try {
const projectId = await getProjectId();
const client = getMonitoringClient();
// Parse relative time expressions like "1h", "2d"
const parseRelativeTime = (timeStr) => {
const now = new Date();
if (timeStr.match(/^\d+[hdwmy]$/)) {
const value = parseInt(timeStr.slice(0, -1));
const unit = timeStr.slice(-1);
switch (unit) {
case 'h': // hours
now.setHours(now.getHours() - value);
break;
case 'd': // days
now.setDate(now.getDate() - value);
break;
case 'w': // weeks
now.setDate(now.getDate() - (value * 7));
break;
case 'm': // months
now.setMonth(now.getMonth() - value);
break;
case 'y': // years
now.setFullYear(now.getFullYear() - value);
break;
}
return now;
}
// Try to parse as ISO date
const date = new Date(timeStr);
if (isNaN(date.getTime())) {
throw new GcpMcpError(`Invalid time format: ${timeStr}`, 'INVALID_ARGUMENT', 400);
}
return date;
};
const start = parseRelativeTime(startTime);
const end = endTime ? parseRelativeTime(endTime) : new Date();
// Build request
const request = {
name: `projects/${projectId}`,
filter,
interval: {
startTime: {
seconds: Math.floor(start.getTime() / 1000),
nanos: 0
},
endTime: {
seconds: Math.floor(end.getTime() / 1000),
nanos: 0
}
}
};
// Add alignment if specified
if (alignmentPeriod) {
// Parse alignment period (e.g., "60s" -> 60 seconds)
const match = alignmentPeriod.match(/^(\d+)([smhd])$/);
if (!match) {
throw new GcpMcpError('Invalid alignment period format. Use format like "60s", "5m", "1h".', 'INVALID_ARGUMENT', 400);
}
const value = parseInt(match[1]);
const unit = match[2];
let seconds = value;
switch (unit) {
case 'm': // minutes
seconds = value * 60;
break;
case 'h': // hours
seconds = value * 60 * 60;
break;
case 'd': // days
seconds = value * 60 * 60 * 24;
break;
}
request.aggregation = {
alignmentPeriod: {
seconds: seconds
},
perSeriesAligner: 'ALIGN_MEAN'
};
}
const [timeSeries] = await client.listTimeSeries(request);
if (!timeSeries || timeSeries.length === 0) {
return {
content: [{
type: 'text',
text: `# Metric Query Results\n\nProject: ${projectId}\nFilter: ${filter}\nTime Range: ${start.toISOString()} to ${end.toISOString()}\n\nNo metrics found matching the filter.`
}]
};
}
const formattedData = formatTimeSeriesData(timeSeries);
return {
content: [{
type: 'text',
text: `# Metric Query Results\n\nProject: ${projectId}\nFilter: ${filter}\nTime Range: ${start.toISOString()} to ${end.toISOString()}\n${alignmentPeriod ? `\nAlignment: ${alignmentPeriod}` : ''}\n\n${formattedData}`
}]
};
}
catch (error) {
console.error('Error in query-metrics tool:', error);
throw new GcpMcpError(`Failed to query metrics: ${error.message}`, error.code || 'UNKNOWN', error.statusCode || 500);
}
});
// Tool to list available metric types
server.tool('list-metric-types', {
filter: z.string().optional().describe('Simple search term (e.g., "spanner") or full filter expression (e.g., "metric.type = starts_with(\\"spanner\\")")'),
pageSize: z.number().min(1).max(100).default(20).describe('Maximum number of metric types to return'),
timeout: z.number().min(5).max(60).default(30).describe('Timeout in seconds for the request')
}, async ({ filter, pageSize, timeout }, context) => {
try {
const projectId = await getProjectId();
const client = getMonitoringClient();
// Format the filter if it's a simple string without operators
let formattedFilter = filter;
let useClientSideFiltering = false;
if (filter && !filter.includes('=') && !filter.includes('>') && !filter.includes('<')) {
// If it's just a simple term, we'll use client-side filtering
// We don't set a filter for the API call to avoid syntax errors
formattedFilter = undefined;
useClientSideFiltering = true;
// If the user provided a search term, we'll filter the results in our code
console.error(`Will filter results for "${filter}" after API call`);
}
console.error(`Listing metric types for project ${projectId} with pageSize=${pageSize}${formattedFilter ? ` and filter=${formattedFilter}` : ''}`);
// Create a promise that rejects after the timeout
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`Request timed out after ${timeout} seconds`));
}, timeout * 1000);
});
// Create the actual request promise
const requestPromise = (async () => {
const request = {
name: `projects/${projectId}`,
pageSize
};
if (formattedFilter) {
request.filter = formattedFilter;
}
console.error('Sending request to Google Cloud Monitoring API...');
return await client.listMetricDescriptors(request);
})();
// Race the timeout against the actual request
const [metricDescriptors] = await Promise.race([requestPromise, timeoutPromise]);
console.error(`Received ${metricDescriptors?.length || 0} metric descriptors`);
// Apply client-side filtering if needed
let filteredDescriptors = metricDescriptors;
if (useClientSideFiltering && filter) {
const searchTerm = filter.toLowerCase();
filteredDescriptors = metricDescriptors.filter((descriptor) => {
// Search in the type name
if (descriptor.type && descriptor.type.toLowerCase().includes(searchTerm)) {
return true;
}
// Search in the display name
if (descriptor.displayName && descriptor.displayName.toLowerCase().includes(searchTerm)) {
return true;
}
// Search in the description
if (descriptor.description && descriptor.description.toLowerCase().includes(searchTerm)) {
return true;
}
return false;
});
console.error(`Filtered down to ${filteredDescriptors.length} metric descriptors containing "${filter}"`);
}
if (!filteredDescriptors || filteredDescriptors.length === 0) {
return {
content: [{
type: 'text',
text: `# Available Metric Types\n\nProject: ${projectId}\n${filter ? `\nSearch term: "${filter}"` : ''}\n\nNo metric types found matching your search term. Try a different search term or increase the timeout.`
}]
};
}
let markdown = `# Available Metric Types\n\nProject: ${projectId}\n${filter ? `\nSearch term: "${filter}"` : ''}\n${useClientSideFiltering ? `\n*Note: Filtering was performed client-side by searching for "${filter}" in metric type, display name, and description.*` : ''}\n\nFound ${filteredDescriptors.length} metric types${metricDescriptors.length !== filteredDescriptors.length ? ` (filtered from ${metricDescriptors.length} total)` : ''}.\n\n`;
// Table header
markdown += '| Metric Type | Display Name | Kind | Value Type | Description |\n';
markdown += '|-------------|--------------|------|------------|-------------|\n';
// Table rows - limit to first 50 to avoid excessive output
const limitedDescriptors = filteredDescriptors.slice(0, 50);
for (const descriptor of limitedDescriptors) {
const description = (descriptor.description || '').replace(/\n/g, ' ').substring(0, 100);
markdown += `| ${descriptor.type || ''} | ${descriptor.displayName || ''} | ${descriptor.metricKind || ''} | ${descriptor.valueType || ''} | ${description} |\n`;
}
if (filteredDescriptors.length > 50) {
markdown += `\n*Note: Showing first 50 of ${filteredDescriptors.length} metric types. Use a more specific search term to narrow down results.*`;
}
return {
content: [{
type: 'text',
text: markdown
}]
};
}
catch (error) {
console.error('Error in list-metric-types tool:', error);
// Extract error message safely
let errorMessage = 'Unknown error';
if (error instanceof Error) {
errorMessage = error.message;
}
else if (typeof error === 'object' && error !== null) {
errorMessage = String(error.message || JSON.stringify(error));
}
else if (typeof error === 'string') {
errorMessage = error;
}
// Check for timeout errors
if (errorMessage.includes('timed out')) {
throw new GcpMcpError(`Request timed out. Try using a filter to narrow down results or increase the timeout parameter.`, 'DEADLINE_EXCEEDED', 504);
}
// Handle other errors
const errorCode = error?.code || 'UNKNOWN';
const statusCode = error?.statusCode || 500;
throw new GcpMcpError(`Failed to list metric types: ${errorMessage}`, errorCode, statusCode);
}
});
}
//# sourceMappingURL=monitoring.js.map