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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ _If you are upgrading from 1.x: please see [Migrating from 1.x](README.md#migrat
- **Breaking:** `find_performance_bottlenecks` now returns one table of the governor limits at risk — `{limit, used, max, usedPercentage}` rows, worst first — beside the `threshold` that selected them. The `cpuBottlenecks`, `databaseBottlenecks`, `methodBottlenecks` and `governorLimitWarnings` sections, the `note`, and the `analysisType` parameter are gone; a new `threshold` parameter sets where a limit becomes worth reporting. All thirteen limits are covered, where the sections covered six, and the response costs 78% less ([#108])
- **Breaking:** drop `file` from `get_apex_log_summary`, and the prose `summary` from `analyze_apex_log_performance` in favour of a scalar share of the runtime ([#86], [#108])
- **Breaking:** `get_apex_log_summary` now reports where the time went and what each namespace consumed. `timeByKind` gives `{kind, logCategory, operationCount, durationSelfMs, selfPercentage}` for every kind of operation, and `limitsByNamespace` gives `{namespace, limit, used}` for each limit a namespace consumed, so a managed package that spends your CPU time is visible. `totalMethods`, `totalSOQLQueries`, `totalDMLOperations`, `totalSOQLRows` and `totalDMLRows` are gone, and searches are covered for the first time. `size`, `totalExecutionTime` and `parsingErrors` are now `fileSizeBytes`, `durationTotalMs` and `parsingErrorCount`, beside a new `truncated`. A `debugLevels` row names its `logCategory`, not its `category`, which is the name `timeByKind` uses for the same fact ([#62], [#108])
- **Breaking:** `execute_anonymous` now reports `succeeded` where it reported `success`, and states `outputDirCreated` in place of the prose tip about `.gitignore`, so the response carries facts alone ([#109])
- **Breaking:** report governor limits as a flat `{limit, used, max}` table, and include the limits at zero, so a caller can tell "no DML ran" from "DML was never read" ([#86], [#62])
- Make the `execute_anonymous` tool always discoverable, so agents can find it without server flags ([#52])
- Reduce every tool response with no fact lost: `analyze_apex_log_performance` by 33% ([#86], [#108]) and `execute_anonymous` by 30% after the first run ([#86]). `get_apex_log_summary` costs 16% more on a log that uses its limits, for the two tables it gained ([#62])
- Reduce every tool response with no fact lost: `analyze_apex_log_performance` by 33% ([#86], [#108]) and `execute_anonymous` by 30% ([#86], [#109]). `get_apex_log_summary` costs 16% more on a log that uses its limits, for the two tables it gained ([#62])
- Reduce the standing cost of having the server connected by 26%: `execute_anonymous` by 49% and `find_performance_bottlenecks` by 25% ([#87], [#108]). `analyze_apex_log_performance` costs 32% more, for the five parameters that select what it ranks ([#108])
- Parse a log once rather than once per tool, cached by path, inode, size, modification time and change time, so a summary followed by a deeper tool no longer reads and parses the file again. The parse is dropped after five minutes unused, so a large log is not held for the life of the session ([#88])

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ Every tool, parameter and response field follows the rules in [DEVELOPING.md](DE

Responses are TOON-encoded and deliberately lean, but the saving comes from shape, never from dropping a fact — see the conventions in [DEVELOPING.md](DEVELOPING.md#️-shaping-tool-responses) and the helpers in `src/tools/responseShaping.ts`. In short: restructure before you delete (flatten nested objects into TOON tables — `toLimitRows` is the worked example, 45% cheaper than the nested form and still complete); always report a fixed-schema field even at zero, because an absent count cannot be told apart from one that was never parsed; use `omitEmpty` **only** for occurrence lists, where absence unambiguously means nothing happened; never report the same figure twice; never state what the caller can derive from the numbers beside it; never echo the caller's input back; round durations and percentages (`roundMs`/`roundPercent`); and keep every row of a table on the same key set so TOON keeps its one-header-plus-one-line-per-row form.

Concretely: `analyze_apex_log_performance` returns no prose `summary` and no `recommendations` — its one unique fact beside the table is the scalar `returnedSelfPercentage`; `get_apex_log_summary` returns no `file`, all thirteen governor limits as `{limit, used, max}` rows, the limits each namespace consumed as `{namespace, limit, used}` rows, one `timeByKind` row per operation kind carrying the trace category that decides whether the kind was logged, the full `debugLevels` list, and omits only `logIssues`; `find_performance_bottlenecks` returns one `atRisk` table of `{limit, used, max, usedPercentage}` rows and reports it even when empty, beside the `threshold` that selected them, because a selection with no stated cutoff cannot be read; `execute_anonymous` emits the `.gitignore` tip only when it created the output directory.
Concretely: `analyze_apex_log_performance` returns no prose `summary` and no `recommendations` — its one unique fact beside the table is the scalar `returnedSelfPercentage`; `get_apex_log_summary` returns no `file`, all thirteen governor limits as `{limit, used, max}` rows, the limits each namespace consumed as `{namespace, limit, used}` rows, one `timeByKind` row per operation kind carrying the trace category that decides whether the kind was logged, the full `debugLevels` list, and omits only `logIssues`; `find_performance_bottlenecks` returns one `atRisk` table of `{limit, used, max, usedPercentage}` rows and reports it even when empty, beside the `threshold` that selected them, because a selection with no stated cutoff cannot be read; `execute_anonymous` states `outputDirCreated` rather than advising the caller to write a `.gitignore`.

The same rule governs the **tool definitions**, which every client loads on every turn whether a tool is called or not (`tools/list` is ~1,020 tokens for the four tools). Say each thing once: an enum already lists its values, so a `.describe()` must not repeat them — this is why `debugLevel` is one `z.partialRecord(z.enum(TRACE_CATEGORIES), z.enum(LOG_LEVELS))` with a single description rather than ten per-category properties, and why `LOG_LEVELS` and `TRACE_CATEGORIES` live only in `src/salesforce/debugLevels.ts`. A description earns its tokens only if the agent acts on it: response-shaping policy is a contributor fact and belongs here and in DEVELOPING.md, not on the wire. Set `title` at the top level only — `annotations.title` is a duplicate alias and is sent twice. Anything true of every tool (durations are milliseconds, which tool to start with) goes in the server `instructions` once.

Expand Down
5 changes: 3 additions & 2 deletions DEVELOPING.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,9 @@ one-liners.
`62.569866677679975`, and every one of those digits is a token nobody reads.
- **Keep table rows identical in shape.** TOON emits one header plus one line per row only while the
rows agree on their keys, so a column is either present on every row or on none.
- **Make conditional information conditional.** A static tip that is only relevant sometimes should
be emitted only then.
- **Report the fact, not the advice.** A tip is a rule applied to a fact the caller cannot see. Report
the fact instead and let the agent apply its own rule: `outputDirCreated` is 4 tokens where the
".gitignore" sentence it replaced was ~15, and it answers the question either way.
- **Say in the tool description what is omitted and when.** Currently that is one sentence per tool,
because there is one omission per tool.

Expand Down
12 changes: 6 additions & 6 deletions src/tools/executeAnonymous.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,7 @@ export async function executeAnonymous(
projectPath ?? process.cwd(),
args.outputDir ?? ".apex-log-mcp",
);
// Resolves to the first directory created, or undefined when it already existed,
// which is exactly when the .gitignore tip below is worth its tokens.
// Resolves to the first directory created, or undefined when it already existed.
const createdDir = await fs.mkdir(outputDir, { recursive: true });

const filePath = path.join(outputDir, `${logId}.log`);
Expand All @@ -276,14 +275,15 @@ export async function executeAnonymous(
fileSizeBytes: stats.size,
org: orgLabel,
orgType: classification,
success: apexResult.success,
succeeded: apexResult.success,
...(apexResult.exceptionMessage && {
exceptionMessage: apexResult.exceptionMessage,
}),
durationMs: logRecord.DurationMilliseconds,
...(createdDir && {
tip: "Add .apex-log-mcp/ to your .gitignore to avoid committing debug logs.",
}),
// A fact about this run, not advice about it: the directory is new, so
// nothing yet ignores it. Reported either way, because an absent field
// cannot be told apart from one this server never worked out.
outputDirCreated: Boolean(createdDir),
}),
},
],
Expand Down
27 changes: 13 additions & 14 deletions tests/executeAnonymous.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ describe("Execute Anonymous", () => {
expect(decoded.filePath).toContain(`${testLogId}.log`);
expect(decoded.fileSizeBytes).toBe(1024);
expect(decoded.org).toBe("test@example.com");
expect(decoded.success).toBe(true);
expect(decoded.succeeded).toBe(true);
expect(decoded.exceptionMessage).toBeUndefined();
expect(decoded.durationMs).toBe(150);
});
Expand Down Expand Up @@ -968,7 +968,7 @@ describe("Execute Anonymous", () => {
expect(toonDecode(result).fileSizeBytes).toBe(2048);
});

it("should include success false and exceptionMessage on runtime failure", async () => {
it("should include succeeded false and exceptionMessage on runtime failure", async () => {
mockExecuteAnonymous.mockResolvedValue({
compiled: true,
success: false,
Expand All @@ -983,36 +983,35 @@ describe("Execute Anonymous", () => {
const result = await executeAnonymous(mockServer, args, policy());

const decoded = toonDecode(result);
expect(decoded.success).toBe(false);
expect(decoded.succeeded).toBe(false);
expect(decoded.exceptionMessage).toBe(
"System.NullPointerException: Attempt to de-reference a null object",
);
expect(decoded.filePath).toContain(`${testLogId}.log`);
});

it("should include the gitignore tip only when it created the output dir", async () => {
it("should say the output dir is new when it created it", async () => {
const args: ExecuteAnonymousArgs = { apex: testApexCode };

// mkdir resolves to the first directory it created, so a value here means the
// caller has a brand new directory that is not yet ignored by git.
// caller has a brand new directory that nothing yet ignores.
mockMkdir.mockResolvedValueOnce("/project/.apex-log-mcp");

expect(toonDecode(await executeAnonymous(mockServer, args, policy())).tip)
.toBe(
"Add .apex-log-mcp/ to your .gitignore to avoid committing debug logs.",
);
expect(
toonDecode(await executeAnonymous(mockServer, args, policy()))
.outputDirCreated,
).toBe(true);
});

it("should omit the gitignore tip when the output dir already existed", async () => {
it("should say the output dir is not new when it already existed", async () => {
const args: ExecuteAnonymousArgs = { apex: testApexCode };

// An existing directory has already been dealt with once, so repeating the
// advice on every run is noise the caller pays for.
mockMkdir.mockResolvedValueOnce(undefined);

expect(
toonDecode(await executeAnonymous(mockServer, args, policy())).tip,
).toBeUndefined();
toonDecode(await executeAnonymous(mockServer, args, policy()))
.outputDirCreated,
).toBe(false);
});
});

Expand Down