From b68fafcc4cad7a12a6a167ae9b4eb89e2501fa0b Mon Sep 17 00:00:00 2001 From: Micah Rairdon Date: Wed, 26 Mar 2025 17:03:25 -0400 Subject: [PATCH 1/4] fix: make AZURE_DEVOPS_AUTH_METHOD parameter case-insensitive --- README.md | 2 +- docs/authentication.md | 2 +- src/index.spec.unit.ts | 100 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 32 ++++++++++++- 4 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 src/index.spec.unit.ts diff --git a/README.md b/README.md index 919e7c5..9da19c5 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Key environment variables include: | Variable | Description | Required | Default | | ------------------------------ | --------------------------------------------------------------- | ------------------ | ------------------ | -| `AZURE_DEVOPS_AUTH_METHOD` | Authentication method (`pat`, `azure-identity`, or `azure-cli`) | No | `azure-identity` | +| `AZURE_DEVOPS_AUTH_METHOD` | Authentication method (`pat`, `azure-identity`, or `azure-cli`) - case-insensitive | No | `azure-identity` | | `AZURE_DEVOPS_ORG` | Azure DevOps organization name | No | Extracted from URL | | `AZURE_DEVOPS_ORG_URL` | Full URL to your Azure DevOps organization | Yes | - | | `AZURE_DEVOPS_PAT` | Personal Access Token (for PAT auth) | Only with PAT auth | - | diff --git a/docs/authentication.md b/docs/authentication.md index bc1b2c8..86e6f9b 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -135,7 +135,7 @@ Azure CLI authentication uses the `AzureCliCredential` class from the `@azure/id | Environment Variable | Description | Required | Default | | ------------------------------ | --------------------------------------------------------------- | ---------------------------- | ---------------- | -| `AZURE_DEVOPS_AUTH_METHOD` | Authentication method (`pat`, `azure-identity`, or `azure-cli`) | No | `azure-identity` | +| `AZURE_DEVOPS_AUTH_METHOD` | Authentication method (`pat`, `azure-identity`, or `azure-cli`) - case-insensitive | No | `azure-identity` | | `AZURE_DEVOPS_ORG_URL` | Full URL to your Azure DevOps organization | Yes | - | | `AZURE_DEVOPS_PAT` | Personal Access Token (for PAT auth) | Only with PAT auth | - | | `AZURE_DEVOPS_DEFAULT_PROJECT` | Default project if none specified | No | - | diff --git a/src/index.spec.unit.ts b/src/index.spec.unit.ts new file mode 100644 index 0000000..cc6a1c6 --- /dev/null +++ b/src/index.spec.unit.ts @@ -0,0 +1,100 @@ +import { normalizeAuthMethod } from './index'; +import { AuthenticationMethod } from './shared/auth/auth-factory'; + +describe('index', () => { + describe('normalizeAuthMethod', () => { + it('should return AzureIdentity when authMethodStr is undefined', () => { + // Arrange + const authMethodStr = undefined; + + // Act + const result = normalizeAuthMethod(authMethodStr); + + // Assert + expect(result).toBe(AuthenticationMethod.AzureIdentity); + }); + + it('should return AzureIdentity when authMethodStr is empty', () => { + // Arrange + const authMethodStr = ''; + + // Act + const result = normalizeAuthMethod(authMethodStr); + + // Assert + expect(result).toBe(AuthenticationMethod.AzureIdentity); + }); + + it('should handle PersonalAccessToken case-insensitively', () => { + // Arrange + const variations = [ + 'pat', + 'PAT', + 'Pat', + 'pAt', + 'paT', + ]; + + // Act & Assert + variations.forEach(variant => { + expect(normalizeAuthMethod(variant)).toBe(AuthenticationMethod.PersonalAccessToken); + }); + }); + + it('should handle AzureIdentity case-insensitively', () => { + // Arrange + const variations = [ + 'azure-identity', + 'AZURE-IDENTITY', + 'Azure-Identity', + 'azure-Identity', + 'Azure-identity', + ]; + + // Act & Assert + variations.forEach(variant => { + expect(normalizeAuthMethod(variant)).toBe(AuthenticationMethod.AzureIdentity); + }); + }); + + it('should handle AzureCli case-insensitively', () => { + // Arrange + const variations = [ + 'azure-cli', + 'AZURE-CLI', + 'Azure-Cli', + 'azure-Cli', + 'Azure-cli', + ]; + + // Act & Assert + variations.forEach(variant => { + expect(normalizeAuthMethod(variant)).toBe(AuthenticationMethod.AzureCli); + }); + }); + + it('should return AzureIdentity for unrecognized values', () => { + // Arrange + const unrecognized = [ + 'unknown', + 'azureCli', // no hyphen + 'azureIdentity', // no hyphen + 'personal-access-token', // not matching enum value + 'cli', + 'identity', + ]; + + // Act & Assert (mute stderr for warning messages) + const originalStderrWrite = process.stderr.write; + process.stderr.write = jest.fn(); + + try { + unrecognized.forEach(value => { + expect(normalizeAuthMethod(value)).toBe(AuthenticationMethod.AzureIdentity); + }); + } finally { + process.stderr.write = originalStderrWrite; + } + }); + }); +}); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index a962a3b..90e9d76 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,35 @@ import dotenv from 'dotenv'; import { AzureDevOpsConfig } from './shared/types'; import { AuthenticationMethod } from './shared/auth/auth-factory'; +/** + * Normalize auth method string to a valid AuthenticationMethod enum value + * in a case-insensitive manner + * + * @param authMethodStr The auth method string from environment variable + * @returns A valid AuthenticationMethod value + */ +export function normalizeAuthMethod(authMethodStr?: string): AuthenticationMethod { + if (!authMethodStr) { + return AuthenticationMethod.AzureIdentity; // Default + } + + // Convert to lowercase for case-insensitive comparison + const normalizedMethod = authMethodStr.toLowerCase(); + + // Check against known enum values (as lowercase strings) + if (normalizedMethod === AuthenticationMethod.PersonalAccessToken.toLowerCase()) { + return AuthenticationMethod.PersonalAccessToken; + } else if (normalizedMethod === AuthenticationMethod.AzureIdentity.toLowerCase()) { + return AuthenticationMethod.AzureIdentity; + } else if (normalizedMethod === AuthenticationMethod.AzureCli.toLowerCase()) { + return AuthenticationMethod.AzureCli; + } + + // If not recognized, log a warning and use the default + process.stderr.write(`WARNING: Unrecognized auth method '${authMethodStr}'. Using default (${AuthenticationMethod.AzureIdentity}).\n`); + return AuthenticationMethod.AzureIdentity; +} + // Load environment variables dotenv.config(); @@ -25,8 +54,7 @@ function getConfig(): AzureDevOpsConfig { return { organizationUrl: process.env.AZURE_DEVOPS_ORG_URL || '', - authMethod: (process.env.AZURE_DEVOPS_AUTH_METHOD || - AuthenticationMethod.AzureIdentity) as AuthenticationMethod, + authMethod: normalizeAuthMethod(process.env.AZURE_DEVOPS_AUTH_METHOD), personalAccessToken: process.env.AZURE_DEVOPS_PAT, defaultProject: process.env.AZURE_DEVOPS_DEFAULT_PROJECT, apiVersion: process.env.AZURE_DEVOPS_API_VERSION, From 3fa1c826e66eed16b1ff244a9d9d82d24e6a1dac Mon Sep 17 00:00:00 2001 From: Micah Rairdon Date: Wed, 26 Mar 2025 17:03:56 -0400 Subject: [PATCH 2/4] Fix auth method case-sensitivity Make the AZURE_DEVOPS_AUTH_METHOD parameter case-insensitive by adding normalization --- package-lock.json | 7 ++- project-management/task-management/doing.md | 20 ++++++ project-management/task-management/todo.md | 6 +- tests/setup.ts | 67 --------------------- 4 files changed, 29 insertions(+), 71 deletions(-) delete mode 100644 tests/setup.ts diff --git a/package-lock.json b/package-lock.json index 442c324..d45af9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "azure-devops-mcp", + "name": "@tiberriver256/mcp-server-azure-devops", "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "azure-devops-mcp", + "name": "@tiberriver256/mcp-server-azure-devops", "version": "0.1.8", "license": "MIT", "dependencies": { @@ -16,6 +16,9 @@ "dotenv": "^16.3.1", "zod": "^3.24.2" }, + "bin": { + "mcp-server-azure-devops": "dist/index.js" + }, "devDependencies": { "@commitlint/cli": "^19.8.0", "@commitlint/config-conventional": "^19.8.0", diff --git a/project-management/task-management/doing.md b/project-management/task-management/doing.md index cecc191..b322898 100644 --- a/project-management/task-management/doing.md +++ b/project-management/task-management/doing.md @@ -70,6 +70,26 @@ * [x] **Sub-task 5.5:** Clean up the temporary file: `git rm --cached temp_test.txt` and `rm temp_test.txt`. * [x] **Sub-task 5.6:** Execute `npm run commit`. **Verify that an interactive prompt appears**, asking questions to build a conventional commit message. Exit the prompt without completing the commit (e.g., using Ctrl+C). +* **Task 1.1**: Fix auth method case-sensitivity issue + * **Role**: Full-Stack Developer + * **Phase**: Completion + * **Description**: Make the `AZURE_DEVOPS_AUTH_METHOD` parameter case-insensitive + + ### Notes + - Currently, the auth method parameter is case-sensitive, causing issues when users enter values with different casing + - Need to research how the auth method is currently implemented + - Need to modify the validation/parsing logic to handle case variations + - Implemented a normalizeAuthMethod function that compares the auth method in a case-insensitive way + - Added comprehensive unit tests to verify case-insensitive behavior + - Updated documentation to clarify that the parameter is case-insensitive + + ### Sub-tasks + - [x] Research current implementation of auth method validation + - [x] Design approach for handling case-insensitive comparison + - [x] Implement the fix with proper error handling + - [x] Add tests to verify the fix works with different case variations + - [x] Update documentation to clarify that the parameter is now case-insensitive + --- **### Phase 2: Setup Release Automation (`standard-version`)** diff --git a/project-management/task-management/todo.md b/project-management/task-management/todo.md index 70f7135..7a8ba7e 100644 --- a/project-management/task-management/todo.md +++ b/project-management/task-management/todo.md @@ -1,8 +1,10 @@ ## Azure DevOps MCP Server Project TODO List (Granular Daily Tasks) -### DevOps +### Authentication and Documentation Enhancements -- [ ] **Task 1.0**: Fix all errors shown by `npm run lint` and add it to .github/workflows/main.yml so they don't build up again. +- [ ] **Task 1.2**: Update documentation on package usage with npx + - **Role**: Technical Writer + - **Description**: Create examples showing how to use the package with Cursor and other environments using mcp.json configuration ### Authentication Enhancements diff --git a/tests/setup.ts b/tests/setup.ts deleted file mode 100644 index 3b5b74b..0000000 --- a/tests/setup.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Jest setup file that runs before all tests - */ - -import dotenv from 'dotenv'; -import path from 'path'; - -// Load environment variables from .env file -const result = dotenv.config({ path: path.resolve(process.cwd(), '.env') }); - -if (result.error) { - console.warn('Warning: .env file not found or cannot be read.'); -} else { - console.log('Environment variables loaded from .env file'); -} - -// Increase timeout for integration tests -jest.setTimeout(30000); // 30 seconds - -// Suppress console output during tests unless specifically desired -const originalConsoleLog = console.log; -const originalConsoleWarn = console.warn; -const originalConsoleError = console.error; - -if (process.env.DEBUG !== 'true') { - global.console.log = (...args: any[]) => { - if ( - args[0]?.toString().includes('Skip') || - args[0]?.toString().includes('Environment') - ) { - originalConsoleLog(...args); - } - }; - - global.console.warn = (...args: any[]) => { - if (args[0]?.toString().includes('Warning')) { - originalConsoleWarn(...args); - } - }; - - global.console.error = (...args: any[]) => { - originalConsoleError(...args); - }; -} - -// Global setup before tests run -beforeAll(() => { - console.log('Starting tests with Testing Trophy approach...'); -}); - -// Global cleanup after all tests -afterAll(() => { - console.log('All tests completed.'); -}); - -// Clear all mocks before each test -beforeEach(() => { - jest.clearAllMocks(); - jest.spyOn(console, 'log').mockImplementation(originalConsoleLog); - jest.spyOn(console, 'warn').mockImplementation(originalConsoleWarn); - jest.spyOn(console, 'error').mockImplementation(originalConsoleError); -}); - -// Restore all mocks after each test -afterEach(() => { - jest.restoreAllMocks(); -}); From b9e16bf6c630571c5c6ec871d33700f783c54ff8 Mon Sep 17 00:00:00 2001 From: Micah Rairdon Date: Wed, 26 Mar 2025 17:07:46 -0400 Subject: [PATCH 3/4] style: fix linting issues --- src/index.spec.unit.ts | 52 ++++++++++++++++++++++-------------------- src/index.ts | 18 +++++++++++---- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/src/index.spec.unit.ts b/src/index.spec.unit.ts index cc6a1c6..0dec45f 100644 --- a/src/index.spec.unit.ts +++ b/src/index.spec.unit.ts @@ -6,10 +6,10 @@ describe('index', () => { it('should return AzureIdentity when authMethodStr is undefined', () => { // Arrange const authMethodStr = undefined; - + // Act const result = normalizeAuthMethod(authMethodStr); - + // Assert expect(result).toBe(AuthenticationMethod.AzureIdentity); }); @@ -17,27 +17,23 @@ describe('index', () => { it('should return AzureIdentity when authMethodStr is empty', () => { // Arrange const authMethodStr = ''; - + // Act const result = normalizeAuthMethod(authMethodStr); - + // Assert expect(result).toBe(AuthenticationMethod.AzureIdentity); }); it('should handle PersonalAccessToken case-insensitively', () => { // Arrange - const variations = [ - 'pat', - 'PAT', - 'Pat', - 'pAt', - 'paT', - ]; - + const variations = ['pat', 'PAT', 'Pat', 'pAt', 'paT']; + // Act & Assert - variations.forEach(variant => { - expect(normalizeAuthMethod(variant)).toBe(AuthenticationMethod.PersonalAccessToken); + variations.forEach((variant) => { + expect(normalizeAuthMethod(variant)).toBe( + AuthenticationMethod.PersonalAccessToken, + ); }); }); @@ -50,10 +46,12 @@ describe('index', () => { 'azure-Identity', 'Azure-identity', ]; - + // Act & Assert - variations.forEach(variant => { - expect(normalizeAuthMethod(variant)).toBe(AuthenticationMethod.AzureIdentity); + variations.forEach((variant) => { + expect(normalizeAuthMethod(variant)).toBe( + AuthenticationMethod.AzureIdentity, + ); }); }); @@ -66,10 +64,12 @@ describe('index', () => { 'azure-Cli', 'Azure-cli', ]; - + // Act & Assert - variations.forEach(variant => { - expect(normalizeAuthMethod(variant)).toBe(AuthenticationMethod.AzureCli); + variations.forEach((variant) => { + expect(normalizeAuthMethod(variant)).toBe( + AuthenticationMethod.AzureCli, + ); }); }); @@ -83,18 +83,20 @@ describe('index', () => { 'cli', 'identity', ]; - + // Act & Assert (mute stderr for warning messages) const originalStderrWrite = process.stderr.write; process.stderr.write = jest.fn(); - + try { - unrecognized.forEach(value => { - expect(normalizeAuthMethod(value)).toBe(AuthenticationMethod.AzureIdentity); + unrecognized.forEach((value) => { + expect(normalizeAuthMethod(value)).toBe( + AuthenticationMethod.AzureIdentity, + ); }); } finally { process.stderr.write = originalStderrWrite; } }); }); -}); \ No newline at end of file +}); diff --git a/src/index.ts b/src/index.ts index 90e9d76..14ed450 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,11 +12,13 @@ import { AuthenticationMethod } from './shared/auth/auth-factory'; /** * Normalize auth method string to a valid AuthenticationMethod enum value * in a case-insensitive manner - * + * * @param authMethodStr The auth method string from environment variable * @returns A valid AuthenticationMethod value */ -export function normalizeAuthMethod(authMethodStr?: string): AuthenticationMethod { +export function normalizeAuthMethod( + authMethodStr?: string, +): AuthenticationMethod { if (!authMethodStr) { return AuthenticationMethod.AzureIdentity; // Default } @@ -25,16 +27,22 @@ export function normalizeAuthMethod(authMethodStr?: string): AuthenticationMetho const normalizedMethod = authMethodStr.toLowerCase(); // Check against known enum values (as lowercase strings) - if (normalizedMethod === AuthenticationMethod.PersonalAccessToken.toLowerCase()) { + if ( + normalizedMethod === AuthenticationMethod.PersonalAccessToken.toLowerCase() + ) { return AuthenticationMethod.PersonalAccessToken; - } else if (normalizedMethod === AuthenticationMethod.AzureIdentity.toLowerCase()) { + } else if ( + normalizedMethod === AuthenticationMethod.AzureIdentity.toLowerCase() + ) { return AuthenticationMethod.AzureIdentity; } else if (normalizedMethod === AuthenticationMethod.AzureCli.toLowerCase()) { return AuthenticationMethod.AzureCli; } // If not recognized, log a warning and use the default - process.stderr.write(`WARNING: Unrecognized auth method '${authMethodStr}'. Using default (${AuthenticationMethod.AzureIdentity}).\n`); + process.stderr.write( + `WARNING: Unrecognized auth method '${authMethodStr}'. Using default (${AuthenticationMethod.AzureIdentity}).\n`, + ); return AuthenticationMethod.AzureIdentity; } From 994f6bb8afa2751557934c46981e938acdde1c63 Mon Sep 17 00:00:00 2001 From: Micah Rairdon Date: Wed, 26 Mar 2025 17:55:35 -0400 Subject: [PATCH 4/4] fix: restore tests/setup.ts to fix test suite --- tests/setup.ts | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/setup.ts diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..3b5b74b --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,67 @@ +/** + * Jest setup file that runs before all tests + */ + +import dotenv from 'dotenv'; +import path from 'path'; + +// Load environment variables from .env file +const result = dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +if (result.error) { + console.warn('Warning: .env file not found or cannot be read.'); +} else { + console.log('Environment variables loaded from .env file'); +} + +// Increase timeout for integration tests +jest.setTimeout(30000); // 30 seconds + +// Suppress console output during tests unless specifically desired +const originalConsoleLog = console.log; +const originalConsoleWarn = console.warn; +const originalConsoleError = console.error; + +if (process.env.DEBUG !== 'true') { + global.console.log = (...args: any[]) => { + if ( + args[0]?.toString().includes('Skip') || + args[0]?.toString().includes('Environment') + ) { + originalConsoleLog(...args); + } + }; + + global.console.warn = (...args: any[]) => { + if (args[0]?.toString().includes('Warning')) { + originalConsoleWarn(...args); + } + }; + + global.console.error = (...args: any[]) => { + originalConsoleError(...args); + }; +} + +// Global setup before tests run +beforeAll(() => { + console.log('Starting tests with Testing Trophy approach...'); +}); + +// Global cleanup after all tests +afterAll(() => { + console.log('All tests completed.'); +}); + +// Clear all mocks before each test +beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(originalConsoleLog); + jest.spyOn(console, 'warn').mockImplementation(originalConsoleWarn); + jest.spyOn(console, 'error').mockImplementation(originalConsoleError); +}); + +// Restore all mocks after each test +afterEach(() => { + jest.restoreAllMocks(); +});