Apex Log MCP Server
MCP Server to Analyze Salesforce Apex debug logs from your AI assistant. Finds slow methods, governor limit risks, and where a transaction spent its time. In your org, runs anonymous Apex, fetches logs, and sets trace flags. Instead of scrolling thousands of log lines, ask what's slow and why. Uses the same Apex log parser as the Apex Log Analyzer VS Code extension.
Quick Start
Requires Node.js 22 or later. Add this to your MCP client config (claude_desktop_config.json, VS Code mcp.json, and so on):
{
"mcpServers": {
"apex-log-mcp": {
"command": "npx",
"args": ["-y", "@certinia/apex-log-mcp"]
}
}
}
Then ask your assistant to analyze a log. The org tools also need an org authenticated with the Salesforce CLI.
Example Prompts
- "Give me a summary of this debug log"
- "Show me the 5 slowest methods in the default namespace"
- "Are we approaching any governor limits in this transaction?"
- "What did my System.debug calls print?"
- "Run this Apex against my scratch org and analyze the performance"
- "Find the latest debug log for [email protected] and summarize it"
- "Why are there no logs for this user?"
Keeping the server connected costs ~2,445 tokens, 1.2% of a 200K context. See Token Cost.
Tools Reference
The analysis tools take an absolute path to a .log file.
Every tool returns one flat table, encoded as TOON. Nothing is repeated, and nothing is dropped to save space.
A 0 means none, not "not measured". Only what did not happen is left out: fatal errors, lost log content, query plans. Durations are in milliseconds to 3 decimal places, percentages to 1.
The server runs as a local process started by your client over stdio, with no API keys. The analysis tools make no network calls. Each log is parsed once, so follow up questions are faster.
apexlog_list_slow_operations
Ranks what a log spent its time on by self time - code units, methods, queries, searches, DML, flows and workflows in one table.
A default response returns:
capturedAt-{debugCategory, level}operations-{debugCategory, type, name, namespace, callCount, durationTotalMs, durationSelfMs, durationSelfMaxMs, selfPercentage, soqlCount, dmlCount, soslCount, rowCount, thrownCount}queryPlans-{operationRow, leadingOperationType, relativeCost, cardinality, sObjectCardinality}
Each response also gives durationTotalMs for the whole transaction, returnedSelfPercentage for the share these rows account for, and matchedCount for the rows that matched before paging. nextOffset, while more remain, is the offset of the next page.
durationSelfMaxMs is the slowest single call in a grouped row. Read it against durationSelfMs to tell one bad call from many small ones. It is absent when the row is already one call.
Two columns say what a row is, both taken straight from the log:
debugCategory- what Salesforce stamped on the event. It decided whether the event was logged at all, and it is the spellingapexlog_execute_anonymoustakes.type- the event type. The category cannot imply it:SOQL_EXECUTE_BEGIN,SOSL_EXECUTE_BEGINandDML_BEGINall sit underdatabase.
Watch for ENTERING_MANAGED_PKG. It is time a package spent where the log shows nothing, and it is often most of a transaction.
sortBy: "heapSelfNetBytes" ranks by retained heap instead of time, adding that column and returnedHeapPercentage. Both are absent otherwise. The figure is signed, so a row that released more than it took reads below zero. Heap is logged only at apexCode FINER and above, so check the apexCode row of capturedAt before trusting a zero.
capturedAt gives the level each category in the returned rows was logged at.
queryPlans is what the query optimizer decided about the queries behind those rows. A relativeCost above 1 means it will not treat the query as selective. Plans are absent when the log explained none, because explain lines need database FINEST.
operationRow points at a row of operations, counting from 1. Under a namespace, callerNamespace or debugCategory grouping a row is not one query, so the plan carries the query text in name instead.
apexlog_get_summary
How long the transaction ran, where the time went, what it consumed, and whether the log is complete. Start here.
fatalErrors-{message, frames}governorLimits-{limit, used, max}limitsByNamespace-{namespace, limit, used}categories-{debugCategory, level, operationCount, durationSelfMs, selfPercentage}
All thirteen governor limits are listed, zeros included.
limitsByNamespace shows what each namespace consumed. This is how you see a managed package spending your CPU time. It has no ceiling column, because a ceiling is per limit for the whole transaction and already sits in governorLimits.
categories covers all eleven, each with the level it was captured at, because the level is what a zero means:
database,NONE,0- the queries were not logged.database,FINEST,0- no queries ran.dataAccess,"",0- the log's header declared no level for it, which most logs do not.
dataAccess, wave and validation are always zero. No timed event carries them.
A last unattributed row holds the time no event spans, such as the time before the transaction starts. With it, the rows add up to the whole log.
truncated says whether the log is complete. In a partial log, every figure is a floor. Where the platform cut it, truncatedBy says how - skipped-lines for a hole, max-size for a missing tail - and skippedBytes says how much went. Both are absent when a log merely stops mid-frame.
thrownCount counts the exceptions thrown, zero included.
exceptions appears when something was thrown: {message, thrownIn, lineNumber, thrownCount}, one row per exception message, most thrown first. A message is the first line of the exception, cut at 200 characters, so two messages that differ only after that share a row. thrownIn and lineNumber say where the message was first thrown. thrownIn is the nearest method, constructor or code unit that the log recorded. Below APEX_CODE,FINE the log records no method, so thrownIn can be a code unit while lineNumber is a line in a method that it called. A managed package states its line as EXTERNAL. The table stops at 20 rows. exceptionGroupCount counts the messages, zero included, so a cut table says so. The log does not say whether a throw was caught.
flowErrorCount counts the flow elements that failed, zero included. A flow can fail with no exception and no fatal error.
fatalErrors appears once per failure that ended the transaction, with the innermost three frames and a trailing … where there were more. It is the only field that says a transaction did not finish, because a fatal error need not breach any limit.
apexlog_list_limit_risks
The governor limits nearest their ceiling, worst first.
capturedAt-{debugCategory, level}atRisk-{limit, used, max, usedPercentage}
threshold is reported beside the rows, so an empty table means nothing reached it rather than that the answer is missing.
capturedAt gives the level that gated each returned limit. Every limit but heap comes from apexProfiling; heapSize comes from apexCode.
apexlog_search_events
Searches a log's events in log order, for what the other tools rank or total: what the code printed (USER_DEBUG), the validation rules, the statements, or every event under one method. Filters combine.
capturedAt-{debugCategory, level}events-{eventIndex, parentEventIndex, type, namespace, lineNumber, text}
A row says what an event is and where it sits, not what it cost: rank the time with apexlog_list_slow_operations. eventIndex names an event and parentEventIndex the one it sits under, so a row found here can be opened up with parentEventIndex. An exit line is a row only where the log lost its entry, and then it is the one record that the method ran.
Text past 400 characters is elided; ask for one event by eventIndex to read up to 30,000 characters of it. contains searches the text a row shows, before elision, and a long row is cut around the match. For plain text, grep the saved .log instead: it is cheaper, and this tool earns its tokens on event types, nesting, and clients with no shell. matchedCount says how many events matched in all; nextOffset, while more remain, is the offset of the next page. capturedAt comes on the first page, or with one event asked for. When every type named matched, or you asked for one event, it gives the levels of the categories the matches came from. Otherwise, or when those name no category the header declared, it gives every level the header declared. A search that finds no USER_DEBUG on a log at apexCode NONE means the log did not capture debug output, not that the code printed none.
apexlog_execute_anonymous
Runs anonymous Apex against an authenticated org, saves the debug log locally, and returns the path. Pass that path to any analysis tool. The Apex runs as the targetOrg user, with that user's permissions. Its DML commits when the run succeeds; a compile failure or an uncaught exception rolls it back.
Give exactly one of apex or apexFilePath, as sf apex run --file takes - for example a script under scripts/apex/. A file outside the roots your client declares is refused, as is anything but a regular file. When the server cannot use your client's roots, a file is refused, and so is a call without targetOrg, since the project's default org cannot be found. That happens on MCP 2026-07-28, when the client does not answer in 5 seconds, or when no root is on this machine. A path costs a few tokens, where inline Apex is read and then written out again. A production confirmation shows the Apex the file holds, not its path.
The response also gives the org username, its alias if set, the org type, and a summary of the run. Logs go to .apex-log-mcp/ by default - add it to your .gitignore. Production orgs are gated: see Production safety.
An object debugLevel looks like this:
{ "database": "FINEST", "apexCode": "FINE" }
Levels are NONE, ERROR, WARN, INFO, DEBUG, FINE, FINER, FINEST.
Set a USER_DEBUG trace flag on your user - in Setup, for example - and every run you start without debugLevel logs at its levels; your flag is never changed. levelsSource in the response says where a run's levels came from: traceFlag, default or request. A live DEVELOPER_LOG trace flag on your user outranks debugLevel and the defaults, so the run takes its levels and levelsSource is traceFlag. levelsOverridden says when the log carries levels other than the ones levelsSource names.
Example prompts:
- "Execute this Apex and show me the log:
System.debug('Hello');" - "Run a query for all Accounts and analyze the performance"
- "Execute this Apex with all debug levels set to FINEST"
- "Run this Apex against my QA org with database logging set to FINEST"
apexlog_list_org_logs
Lists the debug logs stored in an org - a slow UI action, an integration user's request, a Queueable - so an agent can find one it did not run itself. Filters, sorting and paging all run in the org, so a page costs what limit asks for, however many logs the org holds.
matchedCount is how many logs the filters match. exceptionMessage says why a log failed. A cursor pages past the 2,000 rows SOQL's OFFSET stops at; nextCursor is absent on the last page.
apexlog_get_org_logs
Downloads logs by ids or the newest latest, as sf apex get log does. Returns each saved path, for the analysis tools.
Up to 25 a call. A log already saved under outputDir is not downloaded again, since a stored log never changes; downloaded says which were. A log that cannot be downloaded is a row in failed, with the cause, and the rest still save. If apexlog_get_summary reports truncated: true with no truncatedBy for a saved log, the file may have been cut off while it was written: delete it and call this tool again. It reports progress as each log saves. Cancelled, it starts no more downloads; those already running finish and are saved. If the org's login stops working, it also starts no more: those ids are rows in failed, and warning says how to log in again.
apexlog_create_trace_flag
Starts logging a user, so an integration user's requests reach the org's logs, for apexlog_list_org_logs to find. A flag on a class or trigger stores no log itself: it sets the levels of that code's work in the logs a user's flag stores. A user is traced as USER_DEBUG; a class or trigger as CLASS_TRACING.
A flag lives up to 1,439 minutes: Salesforce refuses a flag of a full day. The debug level it uses stays in the org after the flag ends, for the next flag at the same levels.
Every transaction a traced user runs while the flag lives is stored, which can fill the org's 1,000 MB of log storage: when it is full, no one can set a flag until logs are deleted in Setup > Debug Logs, or expire after 24 hours. When the entity already has a flag of its type whose time overlaps the new one, the call is refused and names it, with its levels, its expiry and the sf command that deletes it - a flag is never changed here. Against a production org, the call asks first, naming the entity, the levels and the minutes.
Token Cost
Every request carries every tool definition, whether you call them or not. Each figure below is a whole definition: name, title, description, input schema and annotations.
No single row compares with 1.x: per tool it would compare different tools, since apexlog_list_slow_operations replaced one that took three selection parameters and ranked methods where this one takes eight and ranks every timed event. The total is above 1.x because of the three org tools, which reach logs and trace flags 1.x could not, and apexlog_search_events, which reaches the events 1.x parsed and dropped.
The 4 tools that replaced 1.x's now cost ~1,253 tokens.
A call itself is about 15 tokens - a tool name and a log path - so what a call costs is what it returns.
Cost does not grow with the log size. The figures below are measured against a 40 KB slice of the Apex Log Analyzer sample log. On the full 19.7 MB original, apexlog_get_summary returns ~394 tokens, and apexlog_list_limit_risks the same as on the slice.
Configuration
The Quick Start config gives you every tool.
Production safety
Every tool that changes an org - apexlog_execute_anonymous and apexlog_create_trace_flag - identifies the org first:
For a production org, --allow-production-orgs skips the confirmation. Otherwise the server asks you to confirm, naming the org and what the call would do - for Apex, all of it with its size. Apex over 10,000 characters is refused rather than cut, so it needs the flag. Confirmation needs a client that supports elicitation; without one the call is refused, and the message names both ways to proceed. Each confirmation authorizes one call.
An org that cannot be identified is treated as production, so a network or permissions problem can never quietly downgrade one.
Deny lists
Org type says nothing about whose data an org holds: a customer sandbox runs with no prompt. --deny-orgs refuses an org outright.
"args": [
"-y",
"@certinia/apex-log-mcp",
"--deny-orgs",
"prod-*[email protected],acme--*,type:production"
]
An entry matches the org id, username, alias or instance URL. * is a glob, and the match is anchored and ignores case, so prod-*@acme.com denies [email protected] and not [email protected]. A deny on any alias of a username holds, whichever name the agent uses. Every value comes from the local sf files, so the server refuses a named org before it connects to it.
An org id matches in its 15- or 18-char form, whatever its case. An instance URL matches by its host, so acme--* denies every sandbox of the acme My Domain.
A type: entry denies a type from the table above, e.g. type:sandbox. type:production also denies an org whose type cannot be read, because the server treats that org as production. The server contacts the org before it refuses on type: it connects, and it queries the org type. No Apex runs and no record is written. A type: entry that names no org type stops the server.
Nothing lifts a deny - not --allow-production-orgs, not a confirmation. The refusal names what matched.
A deny covers every org tool, since a log holds the org's data. --deny-orgs '*' refuses them all, and the analysis tools keep working.
Only the org id is unspoofable. An alias can be re-pointed, so treat the rest as convenience, not a security boundary.
Server flags
To stop Apex running:
{
"mcpServers": {
"apex-log-mcp": {
"command": "npx",
"args": ["-y", "@certinia/apex-log-mcp", "--no-apex-execution"]
}
}
}
Documentation
- User Guide & Docs
- Apex Log Analyzer VS Code Extension - the full log analyzer for VS Code
- MCP Specification
Contributing
See the Contributing Guide, Developing to set up your environment, and the Code of Conduct.
License
BSD 3-Clause. Copyright © Certinia Inc. All rights reserved.