Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/mcp-provider-devops/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const TelemetryEventNames = {
CREATE_PULL_REQUEST: 'devops_create_pull_request',
DETECT_CONFLICT: 'devops_detect_conflict',
RESOLVE_CONFLICT: 'devops_resolve_conflict',
UPDATE_WORK_ITEM_STATUS: 'devops_update_work_item_status',
CREATE_WORK_ITEM: 'devops_create_work_item',
} as const;

export const TelemetrySource = 'MCP-DevOps';
Expand Down
67 changes: 67 additions & 0 deletions packages/mcp-provider-devops/src/createWorkItem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import axios from "axios";
import { getConnection } from "./shared/auth.js";

const API_VERSION = "v65.0";

export interface CreateWorkItemParams {
usernameOrAlias: string;
projectId: string;
subject: string;
description: string;
}

export interface CreateWorkItemResult {
success: boolean;
workItemId?: string;
workItemName?: string;
subject?: string;
error?: string;
}

/**
* Creates a new DevOps Center Work Item in the specified project.
* API: POST /services/data/v65.0/connect/devops/projects/<ProjectID>/workitem
* Body: { subject: string, description: string }
*/
export async function createWorkItem(params: CreateWorkItemParams): Promise<CreateWorkItemResult> {
const { usernameOrAlias, projectId, subject, description } = params;

const connection = await getConnection(usernameOrAlias);
const accessToken = connection.accessToken;
const instanceUrl = connection.instanceUrl;
if (!accessToken || !instanceUrl) {
return {
success: false,
error: "Missing access token or instance URL.",
};
}

const url = `${instanceUrl}/services/data/${API_VERSION}/connect/devops/projects/${projectId}/workitem`;
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
};
const body = { subject, description };

try {
const response = await axios.post(url, body, { headers });
const data = response.data ?? {};
return {
success: true,
workItemId: data.id ?? data.Id,
workItemName: data.name ?? data.Name,
subject: data.subject ?? data.Subject ?? subject,
};
} catch (error: any) {
const data = error.response?.data;
const message =
(typeof data === "object" && (data?.message ?? data?.error ?? data?.errorDescription)) ??
error.message ??
"Unknown error";
const details = Array.isArray(data?.body) ? data.body.join("; ") : undefined;
return {
success: false,
error: details ? `${message}: ${details}` : String(message),
};
}
}
4 changes: 4 additions & 0 deletions packages/mcp-provider-devops/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { CheckCommitStatus } from "./tools/checkCommitStatus.js";
import { CreatePullRequest } from "./tools/createPullRequest.js";
import { SfDevopsCheckoutWorkItem } from "./tools/sfDevopsCheckoutWorkItem.js";
import { SfDevopsCommitWorkItem } from "./tools/sfDevopsCommitWorkItem.js";
import { SfDevopsUpdateWorkItemStatus } from "./tools/sfDevopsUpdateWorkItemStatus.js";
import { SfDevopsCreateWorkItem } from "./tools/sfDevopsCreateWorkItem.js";

/**
* DevOps MCPProvider for DevOps tools and operations
Expand All @@ -22,12 +24,14 @@ export class DevOpsMcpProvider extends McpProvider {
return Promise.resolve([
new SfDevopsListProjects(services),
new SfDevopsListWorkItems(services),
new SfDevopsCreateWorkItem(services),
new SfDevopsPromoteWorkItem(services),
new SfDevopsDetectConflict(telemetryService),
new SfDevopsResolveConflict(telemetryService),

new SfDevopsCheckoutWorkItem(services),
new SfDevopsCommitWorkItem(services),
new SfDevopsUpdateWorkItemStatus(services),

new CheckCommitStatus(telemetryService),
new CreatePullRequest(telemetryService),
Expand Down
26 changes: 21 additions & 5 deletions packages/mcp-provider-devops/src/shared/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AuthInfo, OrgAuthorization, Connection } from "@salesforce/core";
import { AuthInfo, OrgAuthorization, Connection, StateAggregator } from "@salesforce/core";
import { SanitizedOrgAuthorization } from "./types.js";

/**
Expand Down Expand Up @@ -31,17 +31,33 @@ export async function getAllAllowedOrgs(): Promise<(SanitizedOrgAuthorization &
return sanitizedOrgs;
}

export async function getConnection(username: string): Promise<Connection> {
export async function getConnection(usernameOrAlias: string): Promise<Connection> {
const allOrgs = await getAllAllowedOrgs();
const foundOrg = findOrgByUsernameOrAlias(allOrgs, username);

let foundOrg = findOrgByUsernameOrAlias(allOrgs, usernameOrAlias);

// If not found, try resolving alias via StateAggregator (CLI alias store).
// listAllAuthorizations() may not include aliases in org.aliases in all environments.
if (!foundOrg) {
try {
await StateAggregator.clearInstanceAsync();
const resolvedUsername = (await StateAggregator.getInstance()).aliases.resolveUsername(
usernameOrAlias
);
if (resolvedUsername && resolvedUsername.includes("@")) {
foundOrg = findOrgByUsernameOrAlias(allOrgs, resolvedUsername);
}
} catch {
// StateAggregator or resolveUsername failed; continue to reject with same error below
}
}

if (!foundOrg)
return Promise.reject(
new Error(
'No org found with the provided username/alias. Ask the user to specify valid username or alias or login with correct org first. use sf cli login command.'
)
);

const authInfo = await AuthInfo.create({ username: foundOrg.username });
const connection = await Connection.create({ authInfo });
return connection;
Expand Down
134 changes: 134 additions & 0 deletions packages/mcp-provider-devops/src/tools/sfDevopsCreateWorkItem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* Copyright 2026, Salesforce, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { z } from "zod";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { McpTool, McpToolConfig, ReleaseState, Toolset, Services } from "@salesforce/mcp-provider-api";
import { createWorkItem } from "../createWorkItem.js";
import { TelemetryEventNames } from "../constants.js";
import { usernameOrAliasParam } from "../shared/params.js";

const inputSchema = z.object({
usernameOrAlias: usernameOrAliasParam,
projectId: z.string().min(1).describe("DevOps Center Project ID selected from list_devops_center_projects for the same org."),
subject: z.string().min(1).describe("Work item subject."),
description: z.string().optional().describe("Work item description."),
});
type InputArgs = z.infer<typeof inputSchema>;
type InputArgsShape = typeof inputSchema.shape;
type OutputArgsShape = z.ZodRawShape;

export class SfDevopsCreateWorkItem extends McpTool<InputArgsShape, OutputArgsShape> {
private readonly services: Services;

constructor(services: Services) {
super();
this.services = services;
}

public getReleaseState(): ReleaseState {
return ReleaseState.NON_GA;
}

public getToolsets(): Toolset[] {
return [Toolset.DEVOPS];
}

public getName(): string {
return "create_devops_center_work_item";
}

public getConfig(): McpToolConfig<InputArgsShape, OutputArgsShape> {
return {
title: "Create Work Item",
description: `Creates a new DevOps Center Work Item in the specified project.

**Usage notes:**
- This tool must be used for the DevOps Center org only. If the org is not provided, use 'list_all_orgs' to select the DevOps Center org.
- A DevOps Center project must be selected first from the same org. If the projectId is not known, call 'list_devops_center_projects' for that org and ask the user to select a project. Use that project's Id here.
- Ensure the org used to select the project is the same org passed to this tool.
- **(Mandatory)** Always ask the user to give the work item subject. Don't proceed until the user has provided the subject. **(Mandatory)**

**API:** POST /services/data/v65.0/connect/devops/projects/<ProjectID>/workitem
**Body:** { "subject": string, "description": string }

**Input parameters:**
- usernameOrAlias: DevOps Center org username or alias. If missing, use 'list_all_orgs' and ask user to select the DevOps Center org.
- projectId: DevOps Center Project ID from list_devops_center_projects for the same org.
- subject: Work item subject.
- description: Work item description (optional).

**Output:**
- success: Whether the create succeeded.
- workItemId, workItemName, subject: Created work item details on success.
- error: Error message if the create failed.`,
inputSchema: inputSchema.shape,
outputSchema: undefined,
annotations: {
readOnlyHint: false, // Creates a work item (modifies state)
destructiveHint: false, // Does not delete anything
openWorldHint: true, // Calls Salesforce DevOps Center API
},
};
}

public async exec(input: InputArgs): Promise<CallToolResult> {
const startTime = Date.now();

try {
const result = await createWorkItem({
usernameOrAlias: input.usernameOrAlias,
projectId: input.projectId,
subject: input.subject,
description: input.description ?? "",
});

const executionTime = Date.now() - startTime;

this.services.getTelemetryService().sendEvent(TelemetryEventNames.CREATE_WORK_ITEM, {
success: result.success,
projectId: input.projectId,
executionTimeMs: executionTime,
...(result.error && { error: result.error }),
});

if (!result.success) {
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
isError: true,
};
}

return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
} catch (e: any) {
const executionTime = Date.now() - startTime;

this.services.getTelemetryService().sendEvent(TelemetryEventNames.CREATE_WORK_ITEM, {
success: false,
error: e?.message || "Unknown error",
projectId: input.projectId,
executionTimeMs: executionTime,
});

return {
content: [{ type: "text", text: `Error creating work item: ${e?.message || e}` }],
isError: true,
};
}
}
}
Loading
Loading