UNPKG

google-cloud-mcp

Version:

Model Context Protocol server for Google Cloud services

409 lines 19.8 kB
/** * 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