@certinia/apex-log-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +28 -0
- package/README.md +216 -0
- package/dist/ApexLogParser.js +2788 -0
- package/dist/index.js +77 -0
- package/dist/salesforce/connection.js +15 -0
- package/dist/salesforce/debugLevels.js +125 -0
- package/dist/salesforce/traceFlags.js +43 -0
- package/dist/salesforce/users.js +13 -0
- package/dist/tools/analyzeLogPerformance.js +150 -0
- package/dist/tools/executeAnonymous.js +182 -0
- package/dist/tools/findPerformanceBottlenecks.js +165 -0
- package/dist/tools/getLogSummary.js +88 -0
- package/package.json +73 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Copyright (c) 2025 Certinia Inc. All rights reserved.
|
|
4
|
+
*/
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { analyzeLogPerformance, analyzeLogPerformanceToolConfig, } from "./tools/analyzeLogPerformance.js";
|
|
9
|
+
import { getLogSummary, getLogSummaryToolConfig, } from "./tools/getLogSummary.js";
|
|
10
|
+
import { findPerformanceBottlenecks, findPerformanceBottlenecksToolConfig, } from "./tools/findPerformanceBottlenecks.js";
|
|
11
|
+
import { executeAnonymous, executeAnonymousToolConfig, } from "./tools/executeAnonymous.js";
|
|
12
|
+
class ApexLogServer {
|
|
13
|
+
server;
|
|
14
|
+
allowedOrgs;
|
|
15
|
+
execAnonTool;
|
|
16
|
+
constructor(allowedOrgs = []) {
|
|
17
|
+
this.allowedOrgs = allowedOrgs;
|
|
18
|
+
this.server = new McpServer({
|
|
19
|
+
name: "apex-log-mcp",
|
|
20
|
+
version: "1.0.0",
|
|
21
|
+
description: "Analyzes Salesforce Apex debug logs for performance bottlenecks, governor limit usage, and optimization opportunities.",
|
|
22
|
+
}, {
|
|
23
|
+
capabilities: {
|
|
24
|
+
tools: {},
|
|
25
|
+
},
|
|
26
|
+
instructions: "Use this server when you have an Apex debug log file to analyze, or when you need to execute anonymous Apex and inspect the resulting log. The log analysis tools accept absolute file paths and return structured data with all durations in milliseconds. Start with get_apex_log_summary for a quick overview, then use analyze_apex_log_performance or find_performance_bottlenecks for deeper analysis. The execute_anonymous tool saves the debug log to a local file and returns a summary with the file path — pass that path to the analysis tools.",
|
|
27
|
+
});
|
|
28
|
+
this.execAnonTool = this.registerTools();
|
|
29
|
+
this.setupErrorHandling();
|
|
30
|
+
}
|
|
31
|
+
setupErrorHandling() {
|
|
32
|
+
this.server.server.onerror = (error) => {
|
|
33
|
+
console.error("[MCP Error]", error);
|
|
34
|
+
};
|
|
35
|
+
const shutdown = async () => {
|
|
36
|
+
this.server.close();
|
|
37
|
+
process.exit(0);
|
|
38
|
+
};
|
|
39
|
+
process.once("SIGINT", shutdown);
|
|
40
|
+
}
|
|
41
|
+
registerTools() {
|
|
42
|
+
this.server.registerTool("analyze_apex_log_performance", analyzeLogPerformanceToolConfig, async (args) => analyzeLogPerformance(args));
|
|
43
|
+
this.server.registerTool("get_apex_log_summary", getLogSummaryToolConfig, async (args) => getLogSummary(args));
|
|
44
|
+
this.server.registerTool("find_performance_bottlenecks", findPerformanceBottlenecksToolConfig, async (args) => findPerformanceBottlenecks(args));
|
|
45
|
+
const execAnon = this.server.registerTool("execute_anonymous", executeAnonymousToolConfig, async (args) => {
|
|
46
|
+
if (this.allowedOrgs.length === 0) {
|
|
47
|
+
throw new Error("execute_anonymous is disabled. Configure --allowed-orgs to enable it.");
|
|
48
|
+
}
|
|
49
|
+
return executeAnonymous(this.server, args, this.allowedOrgs);
|
|
50
|
+
});
|
|
51
|
+
if (this.allowedOrgs.length === 0) {
|
|
52
|
+
execAnon.disable();
|
|
53
|
+
}
|
|
54
|
+
return execAnon;
|
|
55
|
+
}
|
|
56
|
+
async run() {
|
|
57
|
+
const { values } = parseArgs({
|
|
58
|
+
args: process.argv.slice(2),
|
|
59
|
+
options: {
|
|
60
|
+
"allowed-orgs": { type: "string" },
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
this.allowedOrgs = values["allowed-orgs"]
|
|
64
|
+
? values["allowed-orgs"].split(",").map((org) => org.trim())
|
|
65
|
+
: [];
|
|
66
|
+
if (this.allowedOrgs.length > 0) {
|
|
67
|
+
this.execAnonTool.enable();
|
|
68
|
+
}
|
|
69
|
+
const transport = new StdioServerTransport();
|
|
70
|
+
await this.server.connect(transport);
|
|
71
|
+
console.error("Apex Log MCP Server running on stdio");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const server = new ApexLogServer();
|
|
75
|
+
server.run().catch(console.error);
|
|
76
|
+
export { ApexLogServer };
|
|
77
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ConfigAggregator, OrgConfigProperties, Org, } from "@salesforce/core";
|
|
2
|
+
export async function connect(projectPath, targetOrg) {
|
|
3
|
+
const aliasOrUsername = targetOrg ?? (await resolveDefaultOrg(projectPath));
|
|
4
|
+
const org = await Org.create({ aliasOrUsername });
|
|
5
|
+
return org.getConnection();
|
|
6
|
+
}
|
|
7
|
+
async function resolveDefaultOrg(projectPath) {
|
|
8
|
+
const aggregator = await ConfigAggregator.create({ projectPath });
|
|
9
|
+
const defaultOrg = aggregator.getPropertyValue(OrgConfigProperties.TARGET_ORG);
|
|
10
|
+
if (!defaultOrg) {
|
|
11
|
+
throw new Error("No default org configured. Please set a default org using 'sf config set target-org <username>' (use --global if not in a Salesforce DX project).");
|
|
12
|
+
}
|
|
13
|
+
return defaultOrg;
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=connection.js.map
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const DEBUG_LEVEL_SOBJECT = "DebugLevel";
|
|
2
|
+
const DEBUG_LEVEL_NAME = "Apex_Log_MCP_Debug_Level";
|
|
3
|
+
const DEFAULT_TRACE_CONFIG = {
|
|
4
|
+
apexCode: "FINE",
|
|
5
|
+
apexProfiling: "FINE",
|
|
6
|
+
callout: "DEBUG",
|
|
7
|
+
database: "FINEST",
|
|
8
|
+
nba: "INFO",
|
|
9
|
+
system: "DEBUG",
|
|
10
|
+
validation: "DEBUG",
|
|
11
|
+
visualforce: "FINE",
|
|
12
|
+
wave: "INFO",
|
|
13
|
+
workflow: "FINE",
|
|
14
|
+
};
|
|
15
|
+
const LOG_LEVELS = [
|
|
16
|
+
"NONE",
|
|
17
|
+
"ERROR",
|
|
18
|
+
"WARN",
|
|
19
|
+
"INFO",
|
|
20
|
+
"DEBUG",
|
|
21
|
+
"FINE",
|
|
22
|
+
"FINER",
|
|
23
|
+
"FINEST",
|
|
24
|
+
];
|
|
25
|
+
function isLogLevel(value) {
|
|
26
|
+
return typeof value === "string" && LOG_LEVELS.includes(value);
|
|
27
|
+
}
|
|
28
|
+
function resolveLevels(debugLevel) {
|
|
29
|
+
if (debugLevel === "default")
|
|
30
|
+
return resolveAllDefaults();
|
|
31
|
+
if (isLogLevel(debugLevel))
|
|
32
|
+
return allCategoriesAt(debugLevel);
|
|
33
|
+
return toSObjectFields(debugLevel);
|
|
34
|
+
}
|
|
35
|
+
function allCategoriesAt(level) {
|
|
36
|
+
return toSObjectFields({
|
|
37
|
+
apexCode: level,
|
|
38
|
+
apexProfiling: level,
|
|
39
|
+
callout: level,
|
|
40
|
+
database: level,
|
|
41
|
+
nba: level,
|
|
42
|
+
system: level,
|
|
43
|
+
validation: level,
|
|
44
|
+
visualforce: level,
|
|
45
|
+
wave: level,
|
|
46
|
+
workflow: level,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function toSObjectFields(config) {
|
|
50
|
+
const entries = {};
|
|
51
|
+
if (config.apexCode !== undefined)
|
|
52
|
+
entries.ApexCode = config.apexCode;
|
|
53
|
+
if (config.apexProfiling !== undefined)
|
|
54
|
+
entries.ApexProfiling = config.apexProfiling;
|
|
55
|
+
if (config.callout !== undefined)
|
|
56
|
+
entries.Callout = config.callout;
|
|
57
|
+
if (config.database !== undefined)
|
|
58
|
+
entries.Database = config.database;
|
|
59
|
+
if (config.nba !== undefined)
|
|
60
|
+
entries.Nba = config.nba;
|
|
61
|
+
if (config.system !== undefined)
|
|
62
|
+
entries.System = config.system;
|
|
63
|
+
if (config.validation !== undefined)
|
|
64
|
+
entries.Validation = config.validation;
|
|
65
|
+
if (config.visualforce !== undefined)
|
|
66
|
+
entries.Visualforce = config.visualforce;
|
|
67
|
+
if (config.wave !== undefined)
|
|
68
|
+
entries.Wave = config.wave;
|
|
69
|
+
if (config.workflow !== undefined)
|
|
70
|
+
entries.Workflow = config.workflow;
|
|
71
|
+
return entries;
|
|
72
|
+
}
|
|
73
|
+
function resolveAllDefaults() {
|
|
74
|
+
return toSObjectFields(DEFAULT_TRACE_CONFIG);
|
|
75
|
+
}
|
|
76
|
+
export async function getOrCreateDebugLevelId(connection, debugLevel) {
|
|
77
|
+
const existing = await findDebugLevel(connection);
|
|
78
|
+
if (existing) {
|
|
79
|
+
if (debugLevel !== undefined) {
|
|
80
|
+
const levels = resolveLevels(debugLevel);
|
|
81
|
+
await updateDebugLevel(connection, existing.Id, levels);
|
|
82
|
+
}
|
|
83
|
+
return existing.Id;
|
|
84
|
+
}
|
|
85
|
+
const levels = debugLevel === undefined || debugLevel === "default"
|
|
86
|
+
? resolveAllDefaults()
|
|
87
|
+
: isLogLevel(debugLevel)
|
|
88
|
+
? allCategoriesAt(debugLevel)
|
|
89
|
+
: { ...resolveAllDefaults(), ...toSObjectFields(debugLevel) };
|
|
90
|
+
return await createDebugLevel(connection, levels);
|
|
91
|
+
}
|
|
92
|
+
async function findDebugLevel(connection) {
|
|
93
|
+
const result = await connection.tooling.query(`SELECT Id
|
|
94
|
+
FROM ${DEBUG_LEVEL_SOBJECT}
|
|
95
|
+
WHERE DeveloperName = '${DEBUG_LEVEL_NAME}'
|
|
96
|
+
LIMIT 1`);
|
|
97
|
+
if (result.records.length === 0) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const record = result.records[0];
|
|
101
|
+
if (!record.Id) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return record;
|
|
105
|
+
}
|
|
106
|
+
async function updateDebugLevel(connection, id, levels) {
|
|
107
|
+
const result = await connection.tooling
|
|
108
|
+
.sobject(DEBUG_LEVEL_SOBJECT)
|
|
109
|
+
.update({ Id: id, ...levels });
|
|
110
|
+
if (!result.success) {
|
|
111
|
+
throw new Error(`Failed to update DebugLevel: ${JSON.stringify(result.errors)}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function createDebugLevel(connection, levels) {
|
|
115
|
+
const result = await connection.tooling.sobject(DEBUG_LEVEL_SOBJECT).create({
|
|
116
|
+
DeveloperName: DEBUG_LEVEL_NAME,
|
|
117
|
+
MasterLabel: DEBUG_LEVEL_NAME,
|
|
118
|
+
...levels,
|
|
119
|
+
});
|
|
120
|
+
if (!result.success || !result.id) {
|
|
121
|
+
throw new Error("Failed to create DebugLevel");
|
|
122
|
+
}
|
|
123
|
+
return result.id;
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=debugLevels.js.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const TRACE_FLAG_SOBJECT = "TraceFlag";
|
|
2
|
+
const USER_DEBUG = "USER_DEBUG";
|
|
3
|
+
function toDateTimeLiteral(date) {
|
|
4
|
+
return { toString: () => date.toISOString() };
|
|
5
|
+
}
|
|
6
|
+
export async function ensureTraceFlag(connection, tracedEntityId, debugLevelId) {
|
|
7
|
+
const existingTraceFlag = await findActiveTraceFlag(connection, tracedEntityId);
|
|
8
|
+
if (existingTraceFlag) {
|
|
9
|
+
if (existingTraceFlag.DebugLevelId !== debugLevelId) {
|
|
10
|
+
await updateTraceFlag(connection, existingTraceFlag.Id, debugLevelId);
|
|
11
|
+
}
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const createResult = await createTraceFlag(connection, tracedEntityId, debugLevelId);
|
|
15
|
+
if (!createResult.success) {
|
|
16
|
+
throw new Error(`Failed to create TraceFlag: ${JSON.stringify(createResult.errors)}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async function findActiveTraceFlag(connection, tracedEntityId) {
|
|
20
|
+
return await connection.tooling.sobject(TRACE_FLAG_SOBJECT).findOne({
|
|
21
|
+
TracedEntityId: tracedEntityId,
|
|
22
|
+
ExpirationDate: { $gt: toDateTimeLiteral(new Date()) },
|
|
23
|
+
LogType: USER_DEBUG,
|
|
24
|
+
}, ["Id", "TracedEntityId", "DebugLevelId", "StartDate", "ExpirationDate"]);
|
|
25
|
+
}
|
|
26
|
+
async function updateTraceFlag(connection, traceFlagId, debugLevelId) {
|
|
27
|
+
const result = await connection.tooling
|
|
28
|
+
.sobject(TRACE_FLAG_SOBJECT)
|
|
29
|
+
.update({ Id: traceFlagId, DebugLevelId: debugLevelId });
|
|
30
|
+
if (!result.success) {
|
|
31
|
+
throw new Error(`Failed to update TraceFlag: ${JSON.stringify(result.errors)}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function createTraceFlag(connection, tracedEntityId, debugLevelId) {
|
|
35
|
+
return await connection.tooling.sobject(TRACE_FLAG_SOBJECT).create({
|
|
36
|
+
TracedEntityId: tracedEntityId,
|
|
37
|
+
DebugLevelId: debugLevelId,
|
|
38
|
+
StartDate: new Date().toISOString(),
|
|
39
|
+
ExpirationDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
40
|
+
LogType: USER_DEBUG,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=traceFlags.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export async function getUserIdByUsername(connection, username) {
|
|
2
|
+
const sanitisedUsername = username.replace(/'/g, "\\'");
|
|
3
|
+
const result = await connection.query(`SELECT Id FROM User WHERE Username = '${sanitisedUsername}'`);
|
|
4
|
+
if (result.records.length === 0) {
|
|
5
|
+
throw new Error(`User not found with username: ${username}`);
|
|
6
|
+
}
|
|
7
|
+
const userId = result.records[0].Id;
|
|
8
|
+
if (!userId) {
|
|
9
|
+
throw new Error("User Id is undefined");
|
|
10
|
+
}
|
|
11
|
+
return userId;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=users.js.map
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025 Certinia Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import { promises as fs } from "fs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { parse } from "../ApexLogParser.js";
|
|
7
|
+
import { encode } from "@toon-format/toon";
|
|
8
|
+
export const analyzeLogPerformanceInputSchema = {
|
|
9
|
+
logFilePath: z
|
|
10
|
+
.string()
|
|
11
|
+
.describe("Absolute path to the Apex debug log file (.log)"),
|
|
12
|
+
topMethods: z
|
|
13
|
+
.number()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe("Number of slowest methods to return (default: 10)"),
|
|
16
|
+
minDuration: z
|
|
17
|
+
.number()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("Minimum duration in milliseconds to include a method (default: 0)"),
|
|
20
|
+
namespace: z.string().optional().describe("Filter methods by namespace"),
|
|
21
|
+
};
|
|
22
|
+
export const analyzeLogPerformanceToolConfig = {
|
|
23
|
+
title: "Analyze Apex Log Performance",
|
|
24
|
+
description: "Rank methods in an Apex debug log by self-execution time. Returns method names, durations (in ms), SOQL/DML counts, and optimization recommendations. Best for finding which specific methods to optimize.",
|
|
25
|
+
inputSchema: analyzeLogPerformanceInputSchema,
|
|
26
|
+
annotations: {
|
|
27
|
+
title: "Analyze Apex Log Performance",
|
|
28
|
+
readOnlyHint: true,
|
|
29
|
+
destructiveHint: false,
|
|
30
|
+
idempotentHint: true,
|
|
31
|
+
openWorldHint: false,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
const NS_TO_MS = 1_000_000;
|
|
35
|
+
export async function analyzeLogPerformance(args) {
|
|
36
|
+
const { logFilePath, topMethods = 10, minDuration = 0, namespace } = args;
|
|
37
|
+
// Validate file exists
|
|
38
|
+
try {
|
|
39
|
+
await fs.access(logFilePath);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new Error(`Log file not found: ${logFilePath}`);
|
|
43
|
+
}
|
|
44
|
+
// Read and parse log file
|
|
45
|
+
const logContent = await fs.readFile(logFilePath, "utf-8");
|
|
46
|
+
const apexLog = parse(logContent);
|
|
47
|
+
// Convert ms input to ns for internal filtering
|
|
48
|
+
const minDurationNs = minDuration * NS_TO_MS;
|
|
49
|
+
// Extract all methods with their performance data
|
|
50
|
+
const methods = extractMethods(apexLog, minDurationNs, namespace);
|
|
51
|
+
// Sort by self duration (descending)
|
|
52
|
+
methods.sort((a, b) => b.selfDuration - a.selfDuration);
|
|
53
|
+
// Take top N methods
|
|
54
|
+
const slowestMethods = methods.slice(0, topMethods);
|
|
55
|
+
const msMethods = slowestMethods.map((m) => ({
|
|
56
|
+
...m,
|
|
57
|
+
duration: m.duration / NS_TO_MS,
|
|
58
|
+
selfDuration: m.selfDuration / NS_TO_MS,
|
|
59
|
+
}));
|
|
60
|
+
const result = {
|
|
61
|
+
totalMethods: methods.length,
|
|
62
|
+
totalExecutionTime: apexLog.duration.total / NS_TO_MS,
|
|
63
|
+
slowestMethods: msMethods,
|
|
64
|
+
summary: generatePerformanceSummary(msMethods, apexLog.duration.total / NS_TO_MS),
|
|
65
|
+
recommendations: generateRecommendations(msMethods),
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
content: [
|
|
69
|
+
{
|
|
70
|
+
type: "text",
|
|
71
|
+
text: encode(result),
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function extractMethods(apexLog, minDuration, namespaceFilter) {
|
|
77
|
+
const methods = [];
|
|
78
|
+
const totalTime = apexLog.duration.total;
|
|
79
|
+
const traverse = (node) => {
|
|
80
|
+
if (node.type === "CODE_UNIT_STARTED" || // Entry point
|
|
81
|
+
node.type === "METHOD_ENTRY" || // Methods
|
|
82
|
+
node.subCategory === "Method") {
|
|
83
|
+
if (node.duration.total >= minDuration) {
|
|
84
|
+
if (!namespaceFilter || node.namespace === namespaceFilter) {
|
|
85
|
+
methods.push({
|
|
86
|
+
name: node.text || "Unknown Method",
|
|
87
|
+
duration: node.duration.total,
|
|
88
|
+
selfDuration: node.duration.self,
|
|
89
|
+
namespace: node.namespace || "default",
|
|
90
|
+
lineNumber: node.lineNumber,
|
|
91
|
+
dmlCount: node.dmlCount.total,
|
|
92
|
+
soqlCount: node.soqlCount.total,
|
|
93
|
+
dmlRows: node.dmlRowCount.total,
|
|
94
|
+
soqlRows: node.soqlRowCount.total,
|
|
95
|
+
thrownCount: node.totalThrownCount,
|
|
96
|
+
soslCount: node.soslCount.total,
|
|
97
|
+
soslRows: node.soslRowCount.total,
|
|
98
|
+
selfPercentage: totalTime > 0 ? (node.duration.self / totalTime) * 100 : 0,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (node.children) {
|
|
104
|
+
node.children.forEach((child) => traverse(child));
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
traverse(apexLog);
|
|
108
|
+
return methods;
|
|
109
|
+
}
|
|
110
|
+
function generatePerformanceSummary(methods, totalTimeMs) {
|
|
111
|
+
if (methods.length === 0) {
|
|
112
|
+
return "No methods found matching the criteria.";
|
|
113
|
+
}
|
|
114
|
+
const slowestMethod = methods[0];
|
|
115
|
+
const totalSlowMethodsTime = methods.reduce((sum, method) => sum + method.selfDuration, 0);
|
|
116
|
+
const percentageOfTotal = totalTimeMs > 0 ? (totalSlowMethodsTime / totalTimeMs) * 100 : 0;
|
|
117
|
+
return `Analysis found ${methods.length} methods. The slowest method "${slowestMethod.name}" took ${slowestMethod.selfDuration.toFixed(2)}ms (${slowestMethod.selfPercentage.toFixed(1)}% of total execution time). The top ${methods.length} methods account for ${percentageOfTotal.toFixed(1)}% of total execution time.`;
|
|
118
|
+
}
|
|
119
|
+
function generateRecommendations(methods) {
|
|
120
|
+
const recommendations = [];
|
|
121
|
+
for (const method of methods.slice(0, 3)) {
|
|
122
|
+
const recommendation = getRecommendation(method);
|
|
123
|
+
if (recommendation) {
|
|
124
|
+
recommendations.push(recommendation);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (recommendations.length === 0) {
|
|
128
|
+
recommendations.push("Performance looks good! No obvious bottlenecks detected in the analyzed methods.");
|
|
129
|
+
}
|
|
130
|
+
return recommendations;
|
|
131
|
+
}
|
|
132
|
+
function getRecommendation(method) {
|
|
133
|
+
if (method.selfPercentage > 10 && method.selfDuration > 0.1) {
|
|
134
|
+
return `Method "${method.name}" consumes ${method.selfPercentage.toFixed(1)}% self execution time. Consider if it can be optimized to make it faster, check how many times it is called and if that can be reduced.`;
|
|
135
|
+
}
|
|
136
|
+
if (method.soqlRows > 1000) {
|
|
137
|
+
return `Method "${method.name}" processes ${method.soqlRows} SOQL rows. Consider adding WHERE clauses or using pagination.`;
|
|
138
|
+
}
|
|
139
|
+
if (method.soqlCount > 5) {
|
|
140
|
+
return `Method "${method.name}" executes ${method.soqlCount} SOQL queries. Consider reducing query count through bulkification or caching.`;
|
|
141
|
+
}
|
|
142
|
+
if (method.dmlCount > 3) {
|
|
143
|
+
return `Method "${method.name}" performs ${method.dmlCount} DML operations. Consider bulkifying DML operations.`;
|
|
144
|
+
}
|
|
145
|
+
if (method.soslCount > 3) {
|
|
146
|
+
return `Method "${method.name}" executes ${method.soslCount} SOSL searches. Consider reducing search count or caching results.`;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=analyzeLogPerformance.js.map
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ConfigAggregator, OrgConfigProperties, StateAggregator, } from "@salesforce/core";
|
|
5
|
+
import { encode } from "@toon-format/toon";
|
|
6
|
+
import { getUserIdByUsername } from "../salesforce/users.js";
|
|
7
|
+
import { getOrCreateDebugLevelId, } from "../salesforce/debugLevels.js";
|
|
8
|
+
import { ensureTraceFlag } from "../salesforce/traceFlags.js";
|
|
9
|
+
import { connect } from "../salesforce/connection.js";
|
|
10
|
+
const LOG_LEVEL_ENUM = [
|
|
11
|
+
"NONE",
|
|
12
|
+
"ERROR",
|
|
13
|
+
"WARN",
|
|
14
|
+
"INFO",
|
|
15
|
+
"DEBUG",
|
|
16
|
+
"FINE",
|
|
17
|
+
"FINER",
|
|
18
|
+
"FINEST",
|
|
19
|
+
];
|
|
20
|
+
const logLevelSchema = z.enum(LOG_LEVEL_ENUM);
|
|
21
|
+
function logLevelProperty(description) {
|
|
22
|
+
return logLevelSchema.optional().describe(description);
|
|
23
|
+
}
|
|
24
|
+
export const executeAnonymousInputSchema = {
|
|
25
|
+
apex: z.string().describe("The anonymous Apex to be executed"),
|
|
26
|
+
targetOrg: z
|
|
27
|
+
.string()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("Alias or username of the target Salesforce org. Uses the project default if not specified."),
|
|
30
|
+
outputDir: z
|
|
31
|
+
.string()
|
|
32
|
+
.optional()
|
|
33
|
+
.describe("Directory to save the debug log file. Defaults to .apex-log-mcp/ in the project root."),
|
|
34
|
+
debugLevel: z
|
|
35
|
+
.union([
|
|
36
|
+
z
|
|
37
|
+
.enum(["default", ...LOG_LEVEL_ENUM])
|
|
38
|
+
.describe('Use "default" to reset to defaults, or a log level (e.g. "FINEST") to set all categories to that level.'),
|
|
39
|
+
z
|
|
40
|
+
.object({
|
|
41
|
+
apexCode: logLevelProperty("Apex code log level (default: FINE)"),
|
|
42
|
+
apexProfiling: logLevelProperty("Apex profiling log level (default: FINE)"),
|
|
43
|
+
callout: logLevelProperty("Callout log level (default: DEBUG)"),
|
|
44
|
+
database: logLevelProperty("Database log level (default: FINEST)"),
|
|
45
|
+
nba: logLevelProperty("NBA (Next Best Action) log level (default: INFO)"),
|
|
46
|
+
system: logLevelProperty("System log level (default: DEBUG)"),
|
|
47
|
+
validation: logLevelProperty("Validation log level (default: DEBUG)"),
|
|
48
|
+
visualforce: logLevelProperty("Visualforce log level (default: FINE)"),
|
|
49
|
+
wave: logLevelProperty("Wave/Analytics log level (default: INFO)"),
|
|
50
|
+
workflow: logLevelProperty("Workflow log level (default: FINE)"),
|
|
51
|
+
})
|
|
52
|
+
.describe("Override specific log categories. Only specified categories are updated; others remain unchanged."),
|
|
53
|
+
])
|
|
54
|
+
.optional()
|
|
55
|
+
.describe('Optional debug level configuration. Valid log levels: NONE, ERROR, WARN, INFO, DEBUG, FINE, FINER, FINEST. Pass "default" to reset, a single level string to set all categories, or an object with category overrides (apexCode, apexProfiling, callout, database, nba, system, validation, visualforce, wave, workflow).'),
|
|
56
|
+
};
|
|
57
|
+
export const executeAnonymousToolConfig = {
|
|
58
|
+
title: "Execute Anonymous Apex",
|
|
59
|
+
description: "Execute a snippet of anonymous Apex against an authenticated Salesforce org (via SF CLI). Saves the resulting debug log to a local file and returns a summary with the file path. Use the file path with get_apex_log_summary, analyze_apex_log_performance, or find_performance_bottlenecks for deeper analysis.",
|
|
60
|
+
inputSchema: executeAnonymousInputSchema,
|
|
61
|
+
annotations: {
|
|
62
|
+
title: "Execute Anonymous Apex",
|
|
63
|
+
readOnlyHint: false,
|
|
64
|
+
destructiveHint: false,
|
|
65
|
+
idempotentHint: false,
|
|
66
|
+
openWorldHint: true,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
async function getProjectPath(server) {
|
|
70
|
+
try {
|
|
71
|
+
const { roots } = await server.server.listRoots();
|
|
72
|
+
const rootUri = roots[0]?.uri;
|
|
73
|
+
return rootUri ? new URL(rootUri).pathname : undefined;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function resolveConfigProperty(projectPath, property) {
|
|
80
|
+
const aggregator = await ConfigAggregator.create({ projectPath });
|
|
81
|
+
return aggregator.getPropertyValue(property) ?? undefined;
|
|
82
|
+
}
|
|
83
|
+
async function resolveToUsername(aliasOrUsername) {
|
|
84
|
+
const stateAggregator = await StateAggregator.getInstance();
|
|
85
|
+
return stateAggregator.aliases.resolveUsername(aliasOrUsername);
|
|
86
|
+
}
|
|
87
|
+
async function getAliasForUsername(username) {
|
|
88
|
+
const stateAggregator = await StateAggregator.getInstance();
|
|
89
|
+
return stateAggregator.aliases.get(username) ?? undefined;
|
|
90
|
+
}
|
|
91
|
+
async function validateOrgAllowlist(allowedOrgs, username, targetOrg, projectPath) {
|
|
92
|
+
if (allowedOrgs.length === 0) {
|
|
93
|
+
throw new Error("execute_anonymous is disabled. Configure --allowed-orgs to enable it.");
|
|
94
|
+
}
|
|
95
|
+
if (allowedOrgs.includes("ALLOW_ALL_ORGS")) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const resolvedAllowed = [];
|
|
99
|
+
for (const entry of allowedOrgs) {
|
|
100
|
+
if (entry === "DEFAULT_TARGET_ORG") {
|
|
101
|
+
const resolved = await resolveConfigProperty(projectPath, OrgConfigProperties.TARGET_ORG);
|
|
102
|
+
if (resolved) {
|
|
103
|
+
resolvedAllowed.push(await resolveToUsername(resolved));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (entry === "DEFAULT_TARGET_DEV_HUB") {
|
|
107
|
+
const resolved = await resolveConfigProperty(projectPath, OrgConfigProperties.TARGET_DEV_HUB);
|
|
108
|
+
if (resolved) {
|
|
109
|
+
resolvedAllowed.push(await resolveToUsername(resolved));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
resolvedAllowed.push(await resolveToUsername(entry));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const allowed = resolvedAllowed.map((org) => org.toLowerCase());
|
|
117
|
+
const isAllowed = allowed.includes(username.toLowerCase()) ||
|
|
118
|
+
(targetOrg !== undefined && allowed.includes(targetOrg.toLowerCase()));
|
|
119
|
+
if (!isAllowed) {
|
|
120
|
+
throw new Error(`Org "${targetOrg ?? username}" is not in the allowed orgs list. Allowed orgs: ${allowedOrgs.join(", ")}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export async function executeAnonymous(server, args, allowedOrgs = []) {
|
|
124
|
+
const { apex, targetOrg, debugLevel } = args;
|
|
125
|
+
const projectPath = await getProjectPath(server);
|
|
126
|
+
const connection = await connect(projectPath, targetOrg);
|
|
127
|
+
const username = connection.getUsername();
|
|
128
|
+
if (!username) {
|
|
129
|
+
throw new Error("Could not determine username from connection");
|
|
130
|
+
}
|
|
131
|
+
await validateOrgAllowlist(allowedOrgs, username, targetOrg, projectPath);
|
|
132
|
+
const alias = await getAliasForUsername(username);
|
|
133
|
+
const orgLabel = alias ? `${username} (${alias})` : username;
|
|
134
|
+
const userId = await getUserIdByUsername(connection, username);
|
|
135
|
+
await validateTraceFlag(connection, userId, debugLevel);
|
|
136
|
+
const apexResult = await connection.tooling.executeAnonymous(apex);
|
|
137
|
+
if (!apexResult || !apexResult.compiled) {
|
|
138
|
+
throw new Error(`Apex could not be compiled at line ${apexResult.line}, column ${apexResult.column}: ${apexResult.compileProblem}`);
|
|
139
|
+
}
|
|
140
|
+
// Note: There's no way to get the specific log ID from executeAnonymous.
|
|
141
|
+
// We retrieve the most recent log for this user, which could be incorrect
|
|
142
|
+
// if another process creates a log between execution and this query.
|
|
143
|
+
// Future enhancement: present a list of recent logs for user selection.
|
|
144
|
+
const logRecord = (await connection
|
|
145
|
+
.sobject("ApexLog")
|
|
146
|
+
.findOne({ LogUserId: userId }, ["Id", "DurationMilliseconds"], {
|
|
147
|
+
sort: { StartTime: -1 },
|
|
148
|
+
}));
|
|
149
|
+
if (!logRecord) {
|
|
150
|
+
throw new Error(`Could not retrieve log from anonymous execution.`);
|
|
151
|
+
}
|
|
152
|
+
const logId = logRecord.Id;
|
|
153
|
+
const logBody = await connection.request(`/sobjects/ApexLog/${logId}/Body/`);
|
|
154
|
+
const outputDir = args.outputDir ?? path.join(projectPath ?? process.cwd(), ".apex-log-mcp");
|
|
155
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
156
|
+
const filePath = path.join(outputDir, `${logId}.log`);
|
|
157
|
+
await fs.writeFile(filePath, logBody, "utf-8");
|
|
158
|
+
const stats = await fs.stat(filePath);
|
|
159
|
+
return {
|
|
160
|
+
content: [
|
|
161
|
+
{
|
|
162
|
+
type: "text",
|
|
163
|
+
text: encode({
|
|
164
|
+
filePath,
|
|
165
|
+
fileSizeBytes: stats.size,
|
|
166
|
+
org: orgLabel,
|
|
167
|
+
success: apexResult.success,
|
|
168
|
+
...(apexResult.exceptionMessage && {
|
|
169
|
+
exceptionMessage: apexResult.exceptionMessage,
|
|
170
|
+
}),
|
|
171
|
+
durationMs: logRecord.DurationMilliseconds,
|
|
172
|
+
tip: "Add .apex-log-mcp/ to your .gitignore to avoid committing debug logs.",
|
|
173
|
+
}),
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
async function validateTraceFlag(connection, userId, debugLevel) {
|
|
179
|
+
const debugLevelId = await getOrCreateDebugLevelId(connection, debugLevel);
|
|
180
|
+
await ensureTraceFlag(connection, userId, debugLevelId);
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=executeAnonymous.js.map
|