From e38ceea7e3989cae89f5a93104713c02ad62e558 Mon Sep 17 00:00:00 2001 From: Aryaman Gupta Date: Wed, 30 Sep 2026 20:11:43 +0530 Subject: [PATCH 1/2] Fix MCP output schema validation across backup, replication, snapshot, and pool tools. Normalize protobuf timestamps, enums, and int64 fields in shared helpers and handlers so MCP SDK 1.29+ output validation passes. Add required-field fallbacks for sparse API responses, restore backup byte-size schema descriptions, reject incomplete timestamps, and pin brace-expansion for CI security audit. Co-authored-by: Cursor --- package-lock.json | 46 +++--- package.json | 1 + src/tools/backup-tools.test.ts | 20 ++- src/tools/backup-tools.ts | 16 +- src/tools/handlers/backup-handler.test.ts | 156 +++++++++++++++++- src/tools/handlers/backup-handler.ts | 44 ++--- .../handlers/backup-vault-handler.test.ts | 4 +- src/tools/handlers/backup-vault-handler.ts | 30 +--- src/tools/handlers/kms-config-handler.ts | 8 +- src/tools/handlers/quota-rule-handler.ts | 29 ++-- .../handlers/replication-handler.test.ts | 60 +++++++ src/tools/handlers/replication-handler.ts | 31 ++-- src/tools/handlers/snapshot-handler.test.ts | 6 +- src/tools/handlers/snapshot-handler.ts | 22 ++- src/tools/handlers/storage-pool-handler.ts | 55 ++++-- src/tools/replication-tools.ts | 12 +- src/utils/proto-format-utils.test.ts | 54 ++++++ src/utils/proto-format-utils.ts | 71 ++++++++ 18 files changed, 501 insertions(+), 164 deletions(-) create mode 100644 src/utils/proto-format-utils.test.ts create mode 100644 src/utils/proto-format-utils.ts diff --git a/package-lock.json b/package-lock.json index 85e6419..f4cd91d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1874,16 +1874,6 @@ "node": ">=8.0.0" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1950,19 +1940,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3103,9 +3080,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -4028,6 +4005,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimatch/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", diff --git a/package.json b/package.json index 9d444ef..a008cb0 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "zod": "^3.25.76" }, "overrides": { + "brace-expansion": "2.1.7", "fast-uri": "3.1.8" } } diff --git a/src/tools/backup-tools.test.ts b/src/tools/backup-tools.test.ts index bc03cfc..deb967e 100644 --- a/src/tools/backup-tools.test.ts +++ b/src/tools/backup-tools.test.ts @@ -1,8 +1,26 @@ import { describe, expect, it } from 'vitest'; -import { restoreBackupTool } from './backup-tools.js'; +import { z } from 'zod'; +import { getBackupTool, listBackupsTool, restoreBackupTool } from './backup-tools.js'; describe('backup-tools', () => { it('does not expose a restore option for full backup restore', () => { expect(restoreBackupTool.inputSchema).not.toHaveProperty('restoreOption'); }); + + it('accepts ISO 8601 enforcedRetentionEndTime in backup output schemas', () => { + const backup = { + name: 'projects/p1/locations/us-central1/backupVaults/bv1/backups/b1', + backupId: 'b1', + backupVaultId: 'bv1', + state: 'READY', + sourceVolume: 'projects/p1/locations/us-central1/volumes/vol1', + enforcedRetentionEndTime: '2009-02-13T23:31:30.000Z', + }; + + const getSchema = z.object(getBackupTool.outputSchema); + const listSchema = z.object(listBackupsTool.outputSchema); + + expect(() => getSchema.parse(backup)).not.toThrow(); + expect(() => listSchema.parse({ backups: [backup] })).not.toThrow(); + }); }); diff --git a/src/tools/backup-tools.ts b/src/tools/backup-tools.ts index 4c7c3c1..7f30625 100644 --- a/src/tools/backup-tools.ts +++ b/src/tools/backup-tools.ts @@ -52,19 +52,19 @@ export const getBackupTool: ToolConfig = { backupVaultId: z.string().describe('The ID of the backup vault containing the backup'), state: z.string().describe('The current state of the backup'), description: z.string().optional().describe('Description of the backup'), - volumeUsagebytes: z.number().optional().describe('The size of the volume in bytes'), + volumeUsagebytes: z.string().optional().describe('The size of the volume in bytes'), backupType: z.string().optional().describe('The type of the backup'), sourceVolume: z.string().describe('The name of the source volume'), createTime: z.string().optional().describe('The creation time of the backup'), - chainStoragebytes: z.number().optional().describe('The size of the backup chain in bytes'), + chainStoragebytes: z.string().optional().describe('The size of the backup chain in bytes'), satisfiesPzs: z.boolean().optional().describe('Whether the backup satisfies PZS requirements'), satisfiesPzi: z.boolean().optional().describe('Whether the backup satisfies PZI requirements'), volumeRegion: z.string().optional().describe('The region of the source volume'), backupRegion: z.string().optional().describe('The region where the backup is stored'), enforcedRetentionEndTime: z - .number() + .string() .optional() - .describe('The number of days the backup is retained'), + .describe('The time until which the backup is not deletable (ISO 8601)'), sourceSnapshot: z .string() .optional() @@ -98,12 +98,12 @@ export const listBackupsTool: ToolConfig = { backupVaultId: z.string().describe('The ID of the backup vault containing the backup'), state: z.string().describe('The current state of the backup'), description: z.string().optional().describe('Description of the backup'), - volumeUsagebytes: z.number().optional().describe('The size of the volume in bytes'), + volumeUsagebytes: z.string().optional().describe('The size of the volume in bytes'), backupType: z.string().optional().describe('The type of the backup'), sourceVolume: z.string().describe('The name of the source volume'), createTime: z.string().optional().describe('The creation time of the backup'), chainStoragebytes: z - .number() + .string() .optional() .describe('The size of the backup chain in bytes'), satisfiesPzs: z @@ -117,9 +117,9 @@ export const listBackupsTool: ToolConfig = { volumeRegion: z.string().optional().describe('The region of the source volume'), backupRegion: z.string().optional().describe('The region where the backup is stored'), enforcedRetentionEndTime: z - .number() + .string() .optional() - .describe('The number of days the backup is retained'), + .describe('The time until which the backup is not deletable (ISO 8601)'), sourceSnapshot: z .string() .optional() diff --git a/src/tools/handlers/backup-handler.test.ts b/src/tools/handlers/backup-handler.test.ts index 7af76a0..8a447d4 100644 --- a/src/tools/handlers/backup-handler.test.ts +++ b/src/tools/handlers/backup-handler.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { getBackupTool, listBackupsTool } from '../backup-tools.js'; const createClientMock = vi.fn(); @@ -135,9 +137,6 @@ describe('backup-handler', () => { backupVaultId: 'bv1', state: 'UNKNOWN', }); - expect((result.structuredContent as any).sourceVolume).toContain( - 'projects/p1/locations/us-central1/' - ); }); it('getBackupHandler formats sourceVolume and createTime when present', async () => { @@ -175,12 +174,12 @@ describe('backup-handler', () => { createTime: { seconds: 1 }, description: 'd', backupType: 'MANUAL', - chainStoragebytes: 0, + chainStoragebytes: '0', satisfiesPzs: false, satisfiesPzi: false, volumeRegion: 'r1', backupRegion: 'r2', - enforcedRetentionEndTime: 't', + enforcedRetentionEndTime: { seconds: 1234567890, nanos: 500_000_000 }, sourceSnapshot: 'snap', labels: { a: 'b' }, }, @@ -202,12 +201,12 @@ describe('backup-handler', () => { state: 'READY', description: 'd', backupType: 'MANUAL', - chainStoragebytes: 0, + chainStoragebytes: '0', satisfiesPzs: false, satisfiesPzi: false, volumeRegion: 'r1', backupRegion: 'r2', - enforcedRetentionEndTime: 't', + enforcedRetentionEndTime: '2009-02-13T23:31:30.500Z', sourceSnapshot: 'snap', labels: { a: 'b' }, }); @@ -255,6 +254,149 @@ describe('backup-handler', () => { } }); + it('listBackupsHandler preserves protobuf Timestamp nanos in enforcedRetentionEndTime', async () => { + const listBackups = vi.fn().mockResolvedValue([ + [ + { + name: 'projects/p1/locations/us-central1/backupVaults/bv1/backups/b1', + sourceVolume: 'projects/p1/locations/us-central1/volumes/vol1', + state: 'READY', + enforcedRetentionEndTime: { seconds: 1234567890, nanos: 500_000_000 }, + }, + ], + undefined, + undefined, + ]); + createClientMock.mockReturnValue({ listBackups }); + + const { listBackupsHandler } = await import('./backup-handler.js'); + const result = await listBackupsHandler({ + projectId: 'p1', + location: 'us-central1', + backupVaultId: 'bv1', + }); + + expect((result.structuredContent as any).backups[0].enforcedRetentionEndTime).toBe( + '2009-02-13T23:31:30.500Z' + ); + }); + + it('listBackupsHandler preserves backupId when name is absent', async () => { + const listBackups = vi.fn().mockResolvedValue([ + [ + { + backupId: 'b-sparse', + state: 'READY', + sourceVolume: 'projects/p1/locations/us-central1/volumes/vol1', + }, + ], + undefined, + undefined, + ]); + createClientMock.mockReturnValue({ listBackups }); + + const { listBackupsHandler } = await import('./backup-handler.js'); + const result = await listBackupsHandler({ + projectId: 'p1', + location: 'us-central1', + backupVaultId: 'bv1', + }); + + expect((result.structuredContent as any).backups[0]).toMatchObject({ + backupId: 'b-sparse', + state: 'READY', + }); + }); + + it('listBackupsHandler preserves string int64 byte fields', async () => { + const listBackups = vi.fn().mockResolvedValue([ + [ + { + name: 'projects/p1/locations/us-central1/backupVaults/bv1/backups/b1', + sourceVolume: 'projects/p1/locations/us-central1/volumes/vol1', + state: 'READY', + volumeUsagebytes: '12345', + chainStoragebytes: '67890', + }, + ], + undefined, + undefined, + ]); + createClientMock.mockReturnValue({ listBackups }); + + const { listBackupsHandler } = await import('./backup-handler.js'); + const result = await listBackupsHandler({ + projectId: 'p1', + location: 'us-central1', + backupVaultId: 'bv1', + }); + + expect((result.structuredContent as any).backups[0]).toMatchObject({ + volumeUsagebytes: '12345', + chainStoragebytes: '67890', + }); + }); + + it('listBackupsHandler preserves int64 byte fields above MAX_SAFE_INTEGER', async () => { + const listBackups = vi.fn().mockResolvedValue([ + [ + { + name: 'projects/p1/locations/us-central1/backupVaults/bv1/backups/b1', + sourceVolume: 'projects/p1/locations/us-central1/volumes/vol1', + state: 'READY', + volumeUsagebytes: '9007199254740993', + chainStoragebytes: '9007199254740993', + }, + ], + undefined, + undefined, + ]); + createClientMock.mockReturnValue({ listBackups }); + + const { listBackupsHandler } = await import('./backup-handler.js'); + const result = await listBackupsHandler({ + projectId: 'p1', + location: 'us-central1', + backupVaultId: 'bv1', + }); + + expect((result.structuredContent as any).backups[0]).toMatchObject({ + volumeUsagebytes: '9007199254740993', + chainStoragebytes: '9007199254740993', + }); + }); + + it('listBackupsHandler structuredContent passes MCP output schema validation', async () => { + const listBackups = vi.fn().mockResolvedValue([ + [ + { + name: 'projects/p1/locations/us-central1/backupVaults/bv1/backups/b1', + sourceVolume: 'projects/p1/locations/us-central1/volumes/vol1', + state: 'READY', + createTime: { seconds: 1234567890, nanos: 0 }, + enforcedRetentionEndTime: { seconds: 1234567890, nanos: 500_000_000 }, + volumeUsagebytes: '100', + }, + ], + undefined, + undefined, + ]); + createClientMock.mockReturnValue({ listBackups }); + + const listOutputSchema = z.object(listBackupsTool.outputSchema); + const { listBackupsHandler } = await import('./backup-handler.js'); + const result = await listBackupsHandler({ + projectId: 'p1', + location: 'us-central1', + backupVaultId: 'bv1', + }); + + expect(() => listOutputSchema.parse(result.structuredContent)).not.toThrow(); + expect(() => + z.object(getBackupTool.outputSchema).parse(result.structuredContent.backups[0]) + ).not.toThrow(); + }); + it('listBackupsHandler calls listBackups and returns formatted backups + nextPageToken', async () => { const listBackups = vi .fn() diff --git a/src/tools/handlers/backup-handler.ts b/src/tools/handlers/backup-handler.ts index e0789cb..97cf8e0 100644 --- a/src/tools/handlers/backup-handler.ts +++ b/src/tools/handlers/backup-handler.ts @@ -1,13 +1,14 @@ import { ToolHandler } from '../../types/tool.js'; import { NetAppClientFactory } from '../../utils/netapp-client-factory.js'; +import { + formatProtobufTimestamp, + normalizeStringEnum, + toInt64String, +} from '../../utils/proto-format-utils.js'; import { logger } from '../../logger.js'; const log = logger.child({ module: 'backup-handler' }); -function normalizeStringEnum(value: any): string { - return typeof value === 'string' ? value : 'UNKNOWN'; -} - function parseBlockDeviceOsType(input: any): { value?: number; error?: string } { if (input === undefined || input === null) return {}; @@ -42,47 +43,45 @@ function formatBackupData(backup: any): any { if (!backup) return result; if (backup.name) { - // Extract backupId from name (last part after last slash) const nameParts = backup.name.split('/'); result.name = backup.name; result.backupId = nameParts[nameParts.length - 1]; - // Extract backupVaultId from name const backupVaultMatch = backup.name.match(/\/backupVaults\/([^/]+)\/backups\//); if (backupVaultMatch && backupVaultMatch[1]) { result.backupVaultId = backupVaultMatch[1]; } } + if (!result.backupId && backup.backupId) { + result.backupId = String(backup.backupId); + } - // Map source volume if (backup.sourceVolume) { - result.sourceVolume = backup.sourceVolume; // Map sourceName to sourceVolume for schema consistency + result.sourceVolume = backup.sourceVolume; } - // Copy basic properties if (backup.state !== undefined) result.state = normalizeStringEnum(backup.state); - // Map volume usage bytes - result.volumeUsagebytes = backup.volumeUsagebytes; // Keep original for compatibility + result.volumeUsagebytes = toInt64String(backup.volumeUsagebytes, '0'); - // Format timestamps if they exist - if (backup.createTime) { - result.createTime = new Date(backup.createTime.seconds * 1000).toISOString(); - } + const createTime = formatProtobufTimestamp(backup.createTime); + if (createTime) result.createTime = createTime; + + const enforcedRetentionEndTime = formatProtobufTimestamp(backup.enforcedRetentionEndTime); + if (enforcedRetentionEndTime) result.enforcedRetentionEndTime = enforcedRetentionEndTime; - // Copy optional properties according to schema if (backup.description) result.description = backup.description; if (backup.backupType !== undefined) result.backupType = normalizeStringEnum(backup.backupType); - result.chainStoragebytes = backup.chainStoragebytes || 0; + result.chainStoragebytes = toInt64String(backup.chainStoragebytes, '0'); if (backup.satisfiesPzs !== undefined) result.satisfiesPzs = backup.satisfiesPzs; if (backup.satisfiesPzi !== undefined) result.satisfiesPzi = backup.satisfiesPzi; if (backup.volumeRegion) result.volumeRegion = backup.volumeRegion; if (backup.backupRegion) result.backupRegion = backup.backupRegion; - if (backup.enforcedRetentionEndTime) - result.enforcedRetentionEndTime = backup.enforcedRetentionEndTime; result.sourceSnapshot = backup.sourceSnapshot; if (backup.labels) result.labels = backup.labels; + if (!result.state) result.state = 'UNKNOWN'; + return result; } @@ -213,13 +212,6 @@ export const getBackupHandler: ToolHandler = async (args: { [key: string]: any } log.info({ formattedData }, 'Formatted backup data'); - // Ensure all required fields are present - if (!formattedData.state) formattedData.state = 'UNKNOWN'; - if (!formattedData.sourceVolume) { - // Create a default source volume name based on the backup name pattern - formattedData.sourceVolume = `projects/${projectId}/locations/${location}/storagePools/unknown/volumes/unknown`; - } - return { content: [ { diff --git a/src/tools/handlers/backup-vault-handler.test.ts b/src/tools/handlers/backup-vault-handler.test.ts index 92d0c9e..6af3522 100644 --- a/src/tools/handlers/backup-vault-handler.test.ts +++ b/src/tools/handlers/backup-vault-handler.test.ts @@ -209,9 +209,7 @@ describe('backup-vault-handler', () => { backupVaultId: 'bv1', }); - expect(result.structuredContent).toEqual({ - backupVaultType: 'BACKUP_VAULT_TYPE_UNSPECIFIED', - }); + expect(result.structuredContent).toEqual({}); }); it('getBackupVaultHandler formats backupRetentionPolicy when present', async () => { diff --git a/src/tools/handlers/backup-vault-handler.ts b/src/tools/handlers/backup-vault-handler.ts index ed980e9..6826fba 100644 --- a/src/tools/handlers/backup-vault-handler.ts +++ b/src/tools/handlers/backup-vault-handler.ts @@ -1,13 +1,10 @@ import { ToolHandler } from '../../types/tool.js'; import { NetAppClientFactory } from '../../utils/netapp-client-factory.js'; +import { formatProtobufTimestamp, normalizeStringEnum } from '../../utils/proto-format-utils.js'; import { logger } from '../../logger.js'; const log = logger.child({ module: 'backup-vault-handler' }); -function normalizeStringEnum(value: any): string { - return typeof value === 'string' ? value : 'UNKNOWN'; -} - function locationId(value: unknown): string { if (typeof value !== 'string') return ''; const text = value.trim().replace(/\/+$/, ''); @@ -38,25 +35,19 @@ function formatBackupVaultData(backupVault: any): any { if (!backupVault) return result; if (backupVault.name) { - // Extract backupVaultId from name (last part after last slash) const nameParts = backupVault.name.split('/'); result.name = backupVault.name; result.backupVaultId = nameParts[nameParts.length - 1]; } - // Copy basic properties if (backupVault.state !== undefined) result.state = normalizeStringEnum(backupVault.state); - // Format timestamps if they exist - if (backupVault.createTime) { - result.createTime = new Date(backupVault.createTime.seconds * 1000).toISOString(); - } + const createTime = formatProtobufTimestamp(backupVault.createTime); + if (createTime) result.createTime = createTime; - if (backupVault.updateTime) { - result.updateTime = new Date(backupVault.updateTime.seconds * 1000).toISOString(); - } + const updateTime = formatProtobufTimestamp(backupVault.updateTime); + if (updateTime) result.updateTime = updateTime; - // Copy required properties according to the schema if (backupVault.backupVaultType !== undefined) { result.backupVaultType = normalizeStringEnum(backupVault.backupVaultType); } @@ -66,7 +57,6 @@ function formatBackupVaultData(backupVault: any): any { if (backupVault.destinationBackupVault) result.destinationBackupVault = backupVault.destinationBackupVault; - // Copy backup retention policy if it exists if (backupVault.backupRetentionPolicy) { result.backupRetentionPolicy = { backupMinimumEnforcedRetentionDays: @@ -78,7 +68,6 @@ function formatBackupVaultData(backupVault: any): any { }; } - // Copy optional properties if (backupVault.description) result.description = backupVault.description; if (backupVault.labels) result.labels = backupVault.labels; if (backupVault.kmsConfig) result.kmsConfig = backupVault.kmsConfig; @@ -89,6 +78,10 @@ function formatBackupVaultData(backupVault: any): any { result.backupsCryptoKeyVersion = backupVault.backupsCryptoKeyVersion; } + if (!result.state) result.state = 'UNKNOWN'; + if (!result.backupVaultType) result.backupVaultType = 'BACKUP_VAULT_TYPE_UNSPECIFIED'; + if (!result.createTime) result.createTime = ''; + return result; } @@ -290,11 +283,6 @@ export const getBackupVaultHandler: ToolHandler = async (args: { [key: string]: // Format the backup vault data const formattedData = formatBackupVaultData(backupVault); - // Default values for required fields if they're not present in the API response - if (!formattedData.backupVaultType) { - formattedData.backupVaultType = 'BACKUP_VAULT_TYPE_UNSPECIFIED'; - } - return { content: [ { diff --git a/src/tools/handlers/kms-config-handler.ts b/src/tools/handlers/kms-config-handler.ts index f955647..87d46a1 100644 --- a/src/tools/handlers/kms-config-handler.ts +++ b/src/tools/handlers/kms-config-handler.ts @@ -1,6 +1,7 @@ import { ToolHandler } from '../../types/tool.js'; import { z } from 'zod'; import { NetAppClientFactory } from '../../utils/netapp-client-factory.js'; +import { formatProtobufTimestamp, normalizeStringEnum } from '../../utils/proto-format-utils.js'; import { getKmsConfigTool, listKmsConfigsTool } from '../kms-config-tools.js'; import { logger } from '../../logger.js'; @@ -22,14 +23,13 @@ function formatKmsConfigData(config: any): any { } if (config.cryptoKeyName) result.cryptoKeyName = config.cryptoKeyName; - if (config.state) result.state = config.state; + if (config.state !== undefined) result.state = normalizeStringEnum(config.state); if (config.stateDetails) result.stateDetails = config.stateDetails; if (config.instructions) result.instructions = config.instructions; if (config.serviceAccount) result.serviceAccount = config.serviceAccount; - if (config.createTime) { - result.createTime = new Date(config.createTime.seconds * 1000).toISOString(); - } + const createTime = formatProtobufTimestamp(config.createTime); + if (createTime) result.createTime = createTime; if (config.description) result.description = config.description; if (config.labels) result.labels = config.labels; diff --git a/src/tools/handlers/quota-rule-handler.ts b/src/tools/handlers/quota-rule-handler.ts index 9eb8408..05262f8 100644 --- a/src/tools/handlers/quota-rule-handler.ts +++ b/src/tools/handlers/quota-rule-handler.ts @@ -1,13 +1,14 @@ import { ToolHandler } from '../../types/tool.js'; import { NetAppClientFactory } from '../../utils/netapp-client-factory.js'; +import { + formatProtobufTimestamp, + normalizeQuotaType, + normalizeStringEnum, +} from '../../utils/proto-format-utils.js'; import { logger } from '../../logger.js'; const log = logger.child({ module: 'quota-rule-handler' }); -function normalizeStringEnum(value: any): string { - return typeof value === 'string' ? value : 'UNKNOWN'; -} - // Basic runtime validation so we fail fast before calling the NetApp API function validatePathArgs( args: { @@ -58,15 +59,16 @@ function formatQuotaRuleData(rule: any): any { result.name = rule.name; result.quotaRuleId = nameParts[nameParts.length - 1]; } + if (!result.quotaRuleId && rule.quotaRuleId) { + result.quotaRuleId = String(rule.quotaRuleId); + } if (rule.target) result.target = rule.target; - if (rule.type) { - result.type = rule.type; - result.quotaType = rule.type; - } else if (rule.quotaType) { - // fallback if API ever returns legacy field name - result.quotaType = rule.quotaType; - result.type = rule.quotaType; + const quotaTypeInput = rule.type ?? rule.quotaType; + const normalizedQuotaType = normalizeQuotaType(quotaTypeInput); + if (normalizedQuotaType !== undefined) { + result.type = normalizedQuotaType; + result.quotaType = normalizedQuotaType; } if (rule.diskLimitMib !== undefined) { const mib = Number(rule.diskLimitMib); @@ -74,9 +76,8 @@ function formatQuotaRuleData(rule: any): any { } if (rule.state !== undefined) result.state = normalizeStringEnum(rule.state); - if (rule.createTime) { - result.createTime = new Date(rule.createTime.seconds * 1000).toISOString(); - } + const createTime = formatProtobufTimestamp(rule.createTime); + if (createTime) result.createTime = createTime; if (rule.description) result.description = rule.description; if (rule.labels) result.labels = rule.labels; diff --git a/src/tools/handlers/replication-handler.test.ts b/src/tools/handlers/replication-handler.test.ts index 56ae511..97b91df 100644 --- a/src/tools/handlers/replication-handler.test.ts +++ b/src/tools/handlers/replication-handler.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { getReplicationTool, listReplicationsTool } from '../replication-tools.js'; const createClientMock = vi.fn(); @@ -184,6 +186,64 @@ describe('replication-handler', () => { expect((result.structuredContent as any).lastReplicationTime).toMatch(/^\d{4}-/); }); + it('getReplicationHandler maps transferStats.lastTransferEndTime to lastReplicationTime', async () => { + const getReplication = vi.fn().mockResolvedValue([ + { + name: 'projects/p1/locations/us-central1/volumes/vol1/replications/r1', + sourceVolume: 'src', + destinationVolume: 'dst', + state: 'READY', + transferStats: { lastTransferEndTime: { seconds: 1234567890, nanos: 0 } }, + }, + ]); + createClientMock.mockReturnValue({ getReplication }); + + const getOutputSchema = z.object(getReplicationTool.outputSchema); + const { getReplicationHandler } = await import('./replication-handler.js'); + const result = await getReplicationHandler({ + projectId: 'p1', + location: 'us-central1', + volumeId: 'vol1', + replicationId: 'r1', + }); + + expect((result.structuredContent as any).lastReplicationTime).toBe('2009-02-13T23:31:30.000Z'); + expect(() => getOutputSchema.parse(result.structuredContent)).not.toThrow(); + }); + + it('listReplicationsHandler structuredContent passes MCP output schema validation', async () => { + const listReplications = vi.fn().mockResolvedValue([ + [ + { + name: 'projects/p1/locations/us-central1/volumes/vol1/replications/r1', + sourceVolume: 'src', + destinationVolume: 'dst', + state: 'READY', + createTime: { seconds: 1 }, + transferStats: { lastTransferEndTime: { seconds: 2 } }, + }, + ], + undefined, + 'next', + ]); + createClientMock.mockReturnValue({ listReplications }); + + const listOutputSchema = z.object(listReplicationsTool.outputSchema); + const { listReplicationsHandler } = await import('./replication-handler.js'); + const result = await listReplicationsHandler({ + projectId: 'p1', + location: 'us-central1', + volumeId: 'vol1', + }); + + expect(() => + listOutputSchema.parse({ + ...result.structuredContent, + nextPageToken: result.structuredContent.nextPageToken || undefined, + }) + ).not.toThrow(); + }); + it('getReplicationHandler formats all optional fields', async () => { const getReplication = vi.fn().mockResolvedValue([ { diff --git a/src/tools/handlers/replication-handler.ts b/src/tools/handlers/replication-handler.ts index 57a4581..5a99aad 100644 --- a/src/tools/handlers/replication-handler.ts +++ b/src/tools/handlers/replication-handler.ts @@ -1,14 +1,11 @@ import { ToolHandler } from '../../types/tool.js'; import { NetAppClientFactory } from '../../utils/netapp-client-factory.js'; +import { formatProtobufTimestamp, normalizeStringEnum } from '../../utils/proto-format-utils.js'; import { protos } from '@google-cloud/netapp'; import { logger } from '../../logger.js'; const log = logger.child({ module: 'replication-handler' }); -function normalizeStringEnum(value: any): string { - return typeof value === 'string' ? value : 'UNKNOWN'; -} - // Helper to format replication data for responses function formatReplicationData(replication: any): any { const result: any = {}; @@ -16,33 +13,35 @@ function formatReplicationData(replication: any): any { if (!replication) return result; if (replication.name) { - // Extract replicationId from name (last part after last slash) const nameParts = replication.name.split('/'); result.name = replication.name; result.replicationId = nameParts[nameParts.length - 1]; } + if (!result.replicationId && replication.replicationId) { + result.replicationId = String(replication.replicationId); + } - // Copy basic properties if (replication.sourceVolume) result.sourceVolume = replication.sourceVolume; if (replication.destinationVolume) result.destinationVolume = replication.destinationVolume; if (replication.state !== undefined) result.state = normalizeStringEnum(replication.state); if (replication.healthy !== undefined) result.healthy = replication.healthy; - // Format timestamps if they exist - if (replication.createTime) { - result.createTime = new Date(replication.createTime.seconds * 1000).toISOString(); - } + const createTime = formatProtobufTimestamp(replication.createTime); + if (createTime) result.createTime = createTime; - if (replication.lastReplicationTime) { - result.lastReplicationTime = new Date( - replication.lastReplicationTime.seconds * 1000 - ).toISOString(); - } + const lastReplicationTimestamp = + replication.lastReplicationTime ?? replication.transferStats?.lastTransferEndTime; + const lastReplicationTime = formatProtobufTimestamp(lastReplicationTimestamp); + if (lastReplicationTime) result.lastReplicationTime = lastReplicationTime; - // Copy optional properties if (replication.description) result.description = replication.description; if (replication.labels) result.labels = replication.labels; + if (!result.state) result.state = 'UNKNOWN'; + if (!result.sourceVolume) result.sourceVolume = ''; + if (!result.destinationVolume) result.destinationVolume = ''; + if (!result.createTime) result.createTime = ''; + return result; } diff --git a/src/tools/handlers/snapshot-handler.test.ts b/src/tools/handlers/snapshot-handler.test.ts index 9919239..95bbb90 100644 --- a/src/tools/handlers/snapshot-handler.test.ts +++ b/src/tools/handlers/snapshot-handler.test.ts @@ -113,9 +113,7 @@ describe('snapshot-handler', () => { snapshotId: 's1', }); - // snapshotId extracted from name, but volumeId should not be present - expect(result.structuredContent).toMatchObject({ snapshotId: 's1' }); - expect((result.structuredContent as any).volumeId).toBeUndefined(); + expect(result.structuredContent).toMatchObject({ snapshotId: 's1', volumeId: 'unknown' }); }); it('getSnapshotHandler covers error path', async () => { @@ -174,7 +172,7 @@ describe('snapshot-handler', () => { description: 'd', labels: { a: 'b' }, }); - expect((result.structuredContent as any).createTime).toBeUndefined(); + expect((result.structuredContent as any).createTime).toBe(''); }); it('getSnapshotHandler normalizes non-string state to UNKNOWN', async () => { diff --git a/src/tools/handlers/snapshot-handler.ts b/src/tools/handlers/snapshot-handler.ts index d88472b..6873ec2 100644 --- a/src/tools/handlers/snapshot-handler.ts +++ b/src/tools/handlers/snapshot-handler.ts @@ -1,13 +1,10 @@ import { ToolHandler } from '../../types/tool.js'; import { NetAppClientFactory } from '../../utils/netapp-client-factory.js'; +import { formatProtobufTimestamp, normalizeStringEnum } from '../../utils/proto-format-utils.js'; import { logger } from '../../logger.js'; const log = logger.child({ module: 'snapshot-handler' }); -function normalizeStringEnum(value: any): string { - return typeof value === 'string' ? value : 'UNKNOWN'; -} - // Helper to format snapshot data for responses function formatSnapshotData(snapshot: any): any { const result: any = {}; @@ -15,30 +12,31 @@ function formatSnapshotData(snapshot: any): any { if (!snapshot) return result; if (snapshot.name) { - // Extract snapshotId from name (last part after last slash) const nameParts = snapshot.name.split('/'); result.name = snapshot.name; result.snapshotId = nameParts[nameParts.length - 1]; - // Extract volumeId from name const volumeMatch = snapshot.name.match(/\/volumes\/([^/]+)\/snapshots\//); if (volumeMatch && volumeMatch[1]) { result.volumeId = volumeMatch[1]; } } + if (!result.snapshotId && snapshot.snapshotId) { + result.snapshotId = String(snapshot.snapshotId); + } - // Copy basic properties if (snapshot.state !== undefined) result.state = normalizeStringEnum(snapshot.state); - // Format timestamps if they exist - if (snapshot.createTime) { - result.createTime = new Date(snapshot.createTime.seconds * 1000).toISOString(); - } + const createTime = formatProtobufTimestamp(snapshot.createTime); + if (createTime) result.createTime = createTime; - // Copy optional properties if (snapshot.description) result.description = snapshot.description; if (snapshot.labels) result.labels = snapshot.labels; + if (!result.state) result.state = 'UNKNOWN'; + if (!result.volumeId) result.volumeId = 'unknown'; + if (!result.createTime) result.createTime = ''; + return result; } diff --git a/src/tools/handlers/storage-pool-handler.ts b/src/tools/handlers/storage-pool-handler.ts index 2c93095..af49465 100644 --- a/src/tools/handlers/storage-pool-handler.ts +++ b/src/tools/handlers/storage-pool-handler.ts @@ -1,5 +1,10 @@ import { ToolHandler } from '../../types/tool.js'; import { NetAppClientFactory } from '../../utils/netapp-client-factory.js'; +import { + formatProtobufTimestamp, + normalizeNamedEnum, + toInt64Number, +} from '../../utils/proto-format-utils.js'; import { logger } from '../../logger.js'; const log = logger.child({ module: 'storage-pool-handler' }); @@ -77,14 +82,32 @@ function normalizeStoragePoolMode(mode: any): string | undefined { return undefined; } -function formatCreateTime(createTime: any): string | undefined { - const seconds = createTime?.seconds; - if (seconds === undefined || seconds === null) return undefined; +function normalizePoolServiceLevel(value: unknown): string { + return ( + normalizeNamedEnum(value, { + 0: 'SERVICE_LEVEL_UNSPECIFIED', + 1: 'PREMIUM', + 2: 'EXTREME', + 3: 'STANDARD', + 4: 'FLEX', + }) ?? '' + ); +} - const millis = Number(seconds) * 1000; - if (!Number.isFinite(millis)) return undefined; +function normalizePoolQosType(value: unknown): string | undefined { + return normalizeNamedEnum(value, { + 0: 'QOS_TYPE_UNSPECIFIED', + 1: 'AUTO', + 2: 'MANUAL', + }); +} - return new Date(millis).toISOString(); +function normalizePoolEncryptionType(value: unknown): string | undefined { + return normalizeNamedEnum(value, { + 0: 'ENCRYPTION_TYPE_UNSPECIFIED', + 1: 'SERVICE_MANAGED', + 2: 'CLOUD_KMS', + }); } function isOntapPool(pool: { mode?: any }): boolean { @@ -414,16 +437,16 @@ export const getStoragePoolHandler: ToolHandler = async (args: { [key: string]: storagePoolId: storagePoolId, capacityGib: Number(storagePool.capacityGib) || 0, volumeCapacityGib: Number(storagePool.volumeCapacityGib) || 0, - volumecount: storagePool.volumeCount || 0, - serviceLevel: storagePool.serviceLevel || '', + volumecount: toInt64Number(storagePool.volumeCount, 0), + serviceLevel: normalizePoolServiceLevel(storagePool.serviceLevel), state: normalizeStoragePoolState(storagePool.state), - createTime: formatCreateTime(storagePool.createTime), + createTime: formatProtobufTimestamp(storagePool.createTime), description: storagePool.description || '', labels: storagePool.labels || {}, network: storagePool.network, activeDirectory: storagePool.activeDirectory, kmsConfig: storagePool.kmsConfig, - encryptionType: storagePool.encryptionType, + encryptionType: normalizePoolEncryptionType(storagePool.encryptionType), ldapEnabled: storagePool.ldapEnabled ?? false, customPerformanceEnabled: typeof storagePool.customPerformanceEnabled === 'boolean' @@ -433,7 +456,7 @@ export const getStoragePoolHandler: ToolHandler = async (args: { [key: string]: storagePool.totalThroughputMibps !== undefined ? Number(storagePool.totalThroughputMibps) || 0 : undefined, - qosType: storagePool.qosType, + qosType: normalizePoolQosType(storagePool.qosType), allowAutoTiering: storagePool.allowAutoTiering ?? false, storagePoolType: storagePool.type, mode: normalizeStoragePoolMode((storagePool as any).mode), @@ -501,18 +524,18 @@ export const listStoragePoolsHandler: ToolHandler = async (args: { [key: string] return { name: name, storagePoolId: extractedId, - serviceLevel: pool.serviceLevel || '', + serviceLevel: normalizePoolServiceLevel(pool.serviceLevel), capacityGib: Number(pool.capacityGib) || 0, volumeCapacityGib: Number(pool.volumeCapacityGib) || 0, - volumecount: pool.volumeCount || 0, + volumecount: toInt64Number(pool.volumeCount, 0), state: normalizeStoragePoolState(pool.state), - createTime: formatCreateTime(pool.createTime), + createTime: formatProtobufTimestamp(pool.createTime), description: pool.description || '', labels: pool.labels || {}, network: pool.network, activeDirectory: pool.activeDirectory, kmsConfig: pool.kmsConfig, - encryptionType: pool.encryptionType, + encryptionType: normalizePoolEncryptionType(pool.encryptionType), ldapEnabled: pool.ldapEnabled ?? false, customPerformanceEnabled: typeof pool.customPerformanceEnabled === 'boolean' @@ -522,7 +545,7 @@ export const listStoragePoolsHandler: ToolHandler = async (args: { [key: string] pool.totalThroughputMibps !== undefined ? Number(pool.totalThroughputMibps) || 0 : undefined, - qosType: pool.qosType, + qosType: normalizePoolQosType(pool.qosType), allowAutoTiering: pool.allowAutoTiering ?? false, storagePoolType: pool.type, mode: normalizeStoragePoolMode(pool.mode), diff --git a/src/tools/replication-tools.ts b/src/tools/replication-tools.ts index a8ad3b3..b383d20 100644 --- a/src/tools/replication-tools.ts +++ b/src/tools/replication-tools.ts @@ -48,11 +48,11 @@ export const getReplicationTool: ToolConfig = { createTime: z.string().describe('The timestamp when the replication was created'), description: z.string().optional().describe('The description of the replication'), labels: z.record(z.string()).optional().describe('Labels applied to the replication'), - healthy: z.boolean().describe('Whether the replication is healthy'), + healthy: z.boolean().optional().describe('Whether the replication is healthy'), lastReplicationTime: z - .date() + .string() .optional() - .describe('The timestamp of the last successful replication'), + .describe('The timestamp of the last successful replication (ISO 8601)'), }, }; @@ -84,11 +84,11 @@ export const listReplicationsTool: ToolConfig = { createTime: z.string().describe('The timestamp when the replication was created'), description: z.string().optional().describe('The description of the replication'), labels: z.record(z.string()).optional().describe('Labels applied to the replication'), - healthy: z.boolean().describe('Whether the replication is healthy'), + healthy: z.boolean().optional().describe('Whether the replication is healthy'), lastReplicationTime: z - .date() + .string() .optional() - .describe('The timestamp of the last successful replication'), + .describe('The timestamp of the last successful replication (ISO 8601)'), }) ) .describe('List of replications'), diff --git a/src/utils/proto-format-utils.test.ts b/src/utils/proto-format-utils.test.ts new file mode 100644 index 0000000..4a05fce --- /dev/null +++ b/src/utils/proto-format-utils.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { + formatProtobufTimestamp, + normalizeNamedEnum, + normalizeQuotaType, + normalizeStringEnum, + toInt64Number, + toInt64String, +} from './proto-format-utils.js'; + +describe('proto-format-utils', () => { + it('formatProtobufTimestamp preserves nanos', () => { + expect(formatProtobufTimestamp({ seconds: 1234567890, nanos: 500_000_000 })).toBe( + '2009-02-13T23:31:30.500Z' + ); + }); + + it('formatProtobufTimestamp leaves missing or invalid seconds unset', () => { + expect(formatProtobufTimestamp({})).toBeUndefined(); + expect(formatProtobufTimestamp({ nanos: 1 })).toBeUndefined(); + expect(formatProtobufTimestamp({ seconds: 'nope' })).toBeUndefined(); + expect(formatProtobufTimestamp({ seconds: 1, nanos: Number.NaN })).toBeUndefined(); + }); + + it('formatProtobufTimestamp keeps an explicit unix epoch', () => { + expect(formatProtobufTimestamp({ seconds: 0, nanos: 0 })).toBe('1970-01-01T00:00:00.000Z'); + }); + + it('toInt64Number coerces string int64 values', () => { + expect(toInt64Number('12345')).toBe(12345); + expect(toInt64Number(undefined, 7)).toBe(7); + }); + + it('toInt64String preserves decimal int64 strings beyond MAX_SAFE_INTEGER', () => { + expect(toInt64String('9007199254740993')).toBe('9007199254740993'); + expect(toInt64String('12345')).toBe('12345'); + expect(toInt64String(undefined, '7')).toBe('7'); + expect(toInt64String(42)).toBe('42'); + }); + + it('normalizeStringEnum coerces non-string enums', () => { + expect(normalizeStringEnum('READY')).toBe('READY'); + expect(normalizeStringEnum(1)).toBe('UNKNOWN'); + }); + + it('normalizeQuotaType accepts string enum names', () => { + expect(normalizeQuotaType('INDIVIDUAL_USER_QUOTA')).toBe(1); + }); + + it('normalizeNamedEnum maps numeric enum values to names', () => { + expect(normalizeNamedEnum(1, { 1: 'AUTO', 2: 'MANUAL' })).toBe('AUTO'); + expect(normalizeNamedEnum('MANUAL', { 1: 'AUTO', 2: 'MANUAL' })).toBe('MANUAL'); + }); +}); diff --git a/src/utils/proto-format-utils.ts b/src/utils/proto-format-utils.ts new file mode 100644 index 0000000..14447b6 --- /dev/null +++ b/src/utils/proto-format-utils.ts @@ -0,0 +1,71 @@ +export function normalizeStringEnum(value: unknown): string { + return typeof value === 'string' ? value : 'UNKNOWN'; +} + +export function formatProtobufTimestamp(timestamp: unknown): string | undefined { + if (!timestamp || typeof timestamp !== 'object') return undefined; + + const record = timestamp as { seconds?: unknown; nanos?: unknown }; + if (record.seconds === undefined || record.seconds === null) return undefined; + + const seconds = Number(record.seconds); + const nanos = record.nanos === undefined || record.nanos === null ? 0 : Number(record.nanos); + if (!Number.isFinite(seconds) || !Number.isFinite(nanos)) return undefined; + + const millis = seconds * 1000 + Math.floor(nanos / 1_000_000); + const date = new Date(millis); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); +} + +export function toInt64Number(value: unknown, fallback = 0): number { + if (value === undefined || value === null) return fallback; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function toInt64String(value: unknown, fallback = '0'): string { + if (value === undefined || value === null) return fallback; + if (typeof value === 'string') return value; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (typeof value === 'bigint') return value.toString(); + if (typeof value === 'object' && value !== null && 'toString' in value) { + const toString = (value as { toString?: () => unknown }).toString; + if (typeof toString === 'function' && toString !== Object.prototype.toString) { + const str = toString.call(value); + if (typeof str === 'string' && /^-?\d+$/.test(str)) return str; + } + } + return fallback; +} + +export function normalizeNamedEnum( + value: unknown, + nameByNumber: Record +): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === 'string') return value; + if (typeof value === 'number' && nameByNumber[value] !== undefined) { + return nameByNumber[value]; + } + if (typeof value === 'number') return String(value); + return 'UNKNOWN'; +} + +export function normalizeQuotaType(value: unknown): number | undefined { + const enumMap: Record = { + TYPE_UNSPECIFIED: 0, + INDIVIDUAL_USER_QUOTA: 1, + INDIVIDUAL_GROUP_QUOTA: 2, + DEFAULT_USER_QUOTA: 3, + DEFAULT_GROUP_QUOTA: 4, + }; + + if (value === undefined || value === null) return undefined; + if (typeof value === 'number' && Object.values(enumMap).includes(value)) return value; + if (typeof value === 'string') { + const mapped = enumMap[value.trim().toUpperCase()]; + if (mapped !== undefined) return mapped; + } + return undefined; +} From feb0e58fb3ed79458f07de1707c42a88c1d7056e Mon Sep 17 00:00:00 2001 From: Aryaman Gupta Date: Wed, 30 Sep 2026 20:22:59 +0530 Subject: [PATCH 2/2] Scope brace-expansion override to glob minimatch only. The global override forced 2.x onto minimatch 10, which requires brace-expansion 5.x. Co-authored-by: Cursor --- package-lock.json | 40 +++++++++++++++++++++++----------------- package.json | 8 ++++++-- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index f4cd91d..bce32d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1874,6 +1874,16 @@ "node": ">=8.0.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1940,6 +1950,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -4005,23 +4028,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimatch/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/minimatch/node_modules/brace-expansion": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", - "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", diff --git a/package.json b/package.json index a008cb0..ef06c09 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,11 @@ "zod": "^3.25.76" }, "overrides": { - "brace-expansion": "2.1.7", - "fast-uri": "3.1.8" + "fast-uri": "3.1.8", + "glob": { + "minimatch": { + "brace-expansion": "2.1.7" + } + } } }