Skip to content

Commit 2a92403

Browse files
nuthanmunaiahNuthan Munaiah
andauthored
Add Advanced Security Alert tools (#270)
We are introducing two new tools---`advsec_get_alerts` and `advsec_get_alert_details`---in this change to allow customers to work with [GHAzDO (GitHub Advanced Security for Azure DevOps)](https://aka.ms/advanced-security) alerts directly in code editor. ## GitHub issue number N/A ## **Associated Risks** None ## ✅ **PR Checklist** - [x] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [x] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [x] Title of the pull request is clear and informative. - [x] 👌 Code hygiene - [ ] 🔭 ~~Telemetry added, updated, or N/A~~ N/A - [x] 📄 Documentation added, updated, or N/A - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** * Automated unit tests run using `npm run test` * Manual testing with VS Code Agent Mode in Copilot Chat. --------- Co-authored-by: Nuthan Munaiah <[email protected]>
1 parent 78a1e36 commit 2a92403

File tree

6 files changed

+1065
-9
lines changed

6 files changed

+1065
-9
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ Interact with these Azure DevOps services:
119119
- **release_get_definitions**: Retrieve a list of release definitions for a given project.
120120
- **release_get_releases**: Retrieve a list of releases for a given project.
121121

122+
### 🔒 Advanced Security
123+
124+
- **advsec_get_alerts**: Retrieve Advanced Security alerts for a repository.
125+
- **advsec_get_alert_details**: Get detailed information about a specific Advanced Security alert.
126+
122127
### 🧪 Test Plans
123128

124129
- **testplan_create_test_plan**: Create a new test plan in the project.

src/tools.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
54
import { AccessToken } from "@azure/identity";
5+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
66
import { WebApi } from "azure-devops-node-api";
77

8-
import { configureCoreTools } from "./tools/core.js";
9-
import { configureWorkTools } from "./tools/work.js";
8+
import { configureAdvSecTools } from "./tools/advsec.js";
109
import { configureBuildTools } from "./tools/builds.js";
11-
import { configureRepoTools } from "./tools/repos.js";
12-
import { configureWorkItemTools } from "./tools/workitems.js";
10+
import { configureCoreTools } from "./tools/core.js";
1311
import { configureReleaseTools } from "./tools/releases.js";
14-
import { configureWikiTools } from "./tools/wiki.js";
15-
import { configureTestPlanTools } from "./tools/testplans.js";
12+
import { configureRepoTools } from "./tools/repos.js";
1613
import { configureSearchTools } from "./tools/search.js";
14+
import { configureTestPlanTools } from "./tools/testplans.js";
15+
import { configureWikiTools } from "./tools/wiki.js";
16+
import { configureWorkTools } from "./tools/work.js";
17+
import { configureWorkItemTools } from "./tools/workitems.js";
1718

1819
function configureAllTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string) {
1920
configureCoreTools(server, tokenProvider, connectionProvider);
@@ -25,6 +26,7 @@ function configureAllTools(server: McpServer, tokenProvider: () => Promise<Acces
2526
configureWikiTools(server, tokenProvider, connectionProvider);
2627
configureTestPlanTools(server, tokenProvider, connectionProvider);
2728
configureSearchTools(server, tokenProvider, connectionProvider, userAgentProvider);
29+
configureAdvSecTools(server, tokenProvider, connectionProvider);
2830
}
2931

3032
export { configureAllTools };

src/tools/advsec.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { AccessToken } from "@azure/identity";
5+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6+
import { WebApi } from "azure-devops-node-api";
7+
import { AlertType, AlertValidityStatus, Confidence, Severity, State } from "azure-devops-node-api/interfaces/AlertInterfaces.js";
8+
import { z } from "zod";
9+
import { getEnumKeys, mapStringArrayToEnum, mapStringToEnum } from "../utils.js";
10+
11+
const ADVSEC_TOOLS = {
12+
get_alerts: "advsec_get_alerts",
13+
get_alert_details: "advsec_get_alert_details",
14+
};
15+
16+
function configureAdvSecTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>) {
17+
server.tool(
18+
ADVSEC_TOOLS.get_alerts,
19+
"Retrieve Advanced Security alerts for a repository.",
20+
{
21+
project: z.string().describe("The name or ID of the Azure DevOps project."),
22+
repository: z.string().describe("The name or ID of the repository to get alerts for."),
23+
alertType: z
24+
.enum(getEnumKeys(AlertType) as [string, ...string[]])
25+
.optional()
26+
.describe("Filter alerts by type. If not specified, returns all alert types."),
27+
states: z
28+
.array(z.enum(getEnumKeys(State) as [string, ...string[]]))
29+
.optional()
30+
.describe("Filter alerts by state. If not specified, returns alerts in any state."),
31+
severities: z
32+
.array(z.enum(getEnumKeys(Severity) as [string, ...string[]]))
33+
.optional()
34+
.describe("Filter alerts by severity level. If not specified, returns alerts at any severity."),
35+
ruleId: z.string().optional().describe("Filter alerts by rule ID."),
36+
ruleName: z.string().optional().describe("Filter alerts by rule name."),
37+
toolName: z.string().optional().describe("Filter alerts by tool name."),
38+
ref: z.string().optional().describe("Filter alerts by git reference (branch). If not provided and onlyDefaultBranch is true, only includes alerts from default branch."),
39+
onlyDefaultBranch: z.boolean().optional().default(true).describe("If true, only return alerts found on the default branch. Defaults to true."),
40+
confidenceLevels: z
41+
.array(z.enum(getEnumKeys(Confidence) as [string, ...string[]]))
42+
.optional()
43+
.default(["high", "other"])
44+
.describe("Filter alerts by confidence levels. Only applicable for secret alerts. Defaults to both 'high' and 'other'."),
45+
validity: z
46+
.array(z.enum(getEnumKeys(AlertValidityStatus) as [string, ...string[]]))
47+
.optional()
48+
.describe("Filter alerts by validity status. Only applicable for secret alerts."),
49+
top: z.number().optional().default(100).describe("Maximum number of alerts to return. Defaults to 100."),
50+
orderBy: z.enum(["id", "firstSeen", "lastSeen", "fixedOn", "severity"]).optional().default("severity").describe("Order results by specified field. Defaults to 'severity'."),
51+
continuationToken: z.string().optional().describe("Continuation token for pagination."),
52+
},
53+
async ({ project, repository, alertType, states, severities, ruleId, ruleName, toolName, ref, onlyDefaultBranch, confidenceLevels, validity, top, orderBy, continuationToken }) => {
54+
try {
55+
const connection = await connectionProvider();
56+
const alertApi = await connection.getAlertApi();
57+
58+
const isSecretAlert = !alertType || alertType.toLowerCase() === "secret";
59+
const criteria = {
60+
...(alertType && { alertType: mapStringToEnum(alertType, AlertType) }),
61+
...(states && { states: mapStringArrayToEnum(states, State) }),
62+
...(severities && { severities: mapStringArrayToEnum(severities, Severity) }),
63+
...(ruleId && { ruleId }),
64+
...(ruleName && { ruleName }),
65+
...(toolName && { toolName }),
66+
...(ref && { ref }),
67+
...(onlyDefaultBranch !== undefined && { onlyDefaultBranch }),
68+
...(isSecretAlert && confidenceLevels && { confidenceLevels: mapStringArrayToEnum(confidenceLevels, Confidence) }),
69+
...(isSecretAlert && validity && { validity: mapStringArrayToEnum(validity, AlertValidityStatus) }),
70+
};
71+
72+
const result = await alertApi.getAlerts(
73+
project,
74+
repository,
75+
top,
76+
orderBy,
77+
criteria,
78+
undefined, // expand parameter
79+
continuationToken
80+
);
81+
82+
return {
83+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
84+
};
85+
} catch (error) {
86+
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
87+
88+
return {
89+
content: [
90+
{
91+
type: "text",
92+
text: `Error fetching Advanced Security alerts: ${errorMessage}`,
93+
},
94+
],
95+
isError: true,
96+
};
97+
}
98+
}
99+
);
100+
101+
server.tool(
102+
ADVSEC_TOOLS.get_alert_details,
103+
"Get detailed information about a specific Advanced Security alert.",
104+
{
105+
project: z.string().describe("The name or ID of the Azure DevOps project."),
106+
repository: z.string().describe("The name or ID of the repository containing the alert."),
107+
alertId: z.number().describe("The ID of the alert to retrieve details for."),
108+
ref: z.string().optional().describe("Git reference (branch) to filter the alert."),
109+
},
110+
async ({ project, repository, alertId, ref }) => {
111+
try {
112+
const connection = await connectionProvider();
113+
const alertApi = await connection.getAlertApi();
114+
115+
const result = await alertApi.getAlert(
116+
project,
117+
alertId,
118+
repository,
119+
ref,
120+
undefined // expand parameter
121+
);
122+
123+
return {
124+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
125+
};
126+
} catch (error) {
127+
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
128+
129+
return {
130+
content: [
131+
{
132+
type: "text",
133+
text: `Error fetching alert details: ${errorMessage}`,
134+
},
135+
],
136+
isError: true,
137+
};
138+
}
139+
}
140+
);
141+
}
142+
143+
export { ADVSEC_TOOLS, configureAdvSecTools };

src/utils.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,37 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4-
import { packageVersion } from "./version.js";
5-
64
export const apiVersion = "7.2-preview.1";
75
export const batchApiVersion = "5.0";
86
export const markdownCommentsApiVersion = "7.2-preview.4";
97

8+
export function createEnumMapping<T extends Record<string, string | number>>(enumObject: T): Record<string, T[keyof T]> {
9+
const mapping: Record<string, T[keyof T]> = {};
10+
for (const [key, value] of Object.entries(enumObject)) {
11+
if (typeof key === "string" && typeof value === "number") {
12+
mapping[key.toLowerCase()] = value as T[keyof T];
13+
}
14+
}
15+
return mapping;
16+
}
17+
18+
export function mapStringToEnum<T extends Record<string, string | number>>(value: string | undefined, enumObject: T, defaultValue?: T[keyof T]): T[keyof T] | undefined {
19+
if (!value) return defaultValue;
20+
const enumMapping = createEnumMapping(enumObject);
21+
return enumMapping[value.toLowerCase()] ?? defaultValue;
22+
}
23+
24+
/**
25+
* Maps an array of strings to an array of enum values, filtering out invalid values.
26+
* @param values Array of string values to map
27+
* @param enumObject The enum object to map to
28+
* @returns Array of valid enum values
29+
*/
30+
export function mapStringArrayToEnum<T extends Record<string, string | number>>(values: string[] | undefined, enumObject: T): Array<T[keyof T]> {
31+
if (!values) return [];
32+
return values.map((value) => mapStringToEnum(value, enumObject)).filter((v): v is T[keyof T] => v !== undefined);
33+
}
34+
1035
/**
1136
* Converts a TypeScript numeric enum to an array of string keys for use with z.enum().
1237
* This ensures that enum schemas generate string values rather than numeric values.

0 commit comments

Comments
 (0)