From f357bf96f4152af8e3167e65b3c4892f56ed621c Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Sun, 3 May 2026 20:32:52 -0400 Subject: [PATCH 01/14] Add script debugger support to MCP tools, CLI, and docs - Add 13 MCP debug tools (debug_start_session, debug_set_breakpoints, debug_wait_for_stop, debug_get_stack, debug_get_variables, debug_evaluate, debug_continue, debug_step_over/into/out, debug_end_session, debug_list_sessions, debug_capture_at_breakpoint) to CARTRIDGES, SCAPI, and STOREFRONTNEXT toolsets - Add ServerContext for persistent server-scoped state across MCP tool invocations (debug sessions, future log watches) - Add DebugSessionRegistry with TTL cleanup and multi-session support - Add `b2c debug cli` command with interactive REPL and --rpc mode for JSONL-over-stdio headless/agent integration - Add resolveBreakpointPath utility in SDK for flexible path normalization (server paths, local paths, cartridge-prefixed paths) - Add debug command docs and b2c-debug agent skill --- .changeset/add-debug-cli-command.md | 5 + .changeset/add-debug-diagnostics-toolset.md | 5 + .changeset/add-debug-skill-and-docs.md | 6 + .changeset/add-mcp-server-context.md | 5 + .changeset/add-resolve-breakpoint-path.md | 5 + docs/.vitepress/config.mts | 1 + docs/cli/debug.md | 250 +++++++++++++ packages/b2c-cli/src/commands/debug/cli.ts | 129 +++++++ packages/b2c-cli/src/utils/debug/repl.ts | 350 ++++++++++++++++++ packages/b2c-cli/src/utils/debug/rpc.ts | 310 ++++++++++++++++ packages/b2c-dx-mcp/src/commands/mcp.ts | 24 +- packages/b2c-dx-mcp/src/registry.ts | 11 +- packages/b2c-dx-mcp/src/server-context.ts | 19 + packages/b2c-dx-mcp/src/services.ts | 10 + packages/b2c-dx-mcp/src/tools/adapter.ts | 9 + .../debug-capture-at-breakpoint.ts | 211 +++++++++++ .../src/tools/diagnostics/debug-continue.ts | 56 +++ .../tools/diagnostics/debug-end-session.ts | 70 ++++ .../src/tools/diagnostics/debug-evaluate.ts | 64 ++++ .../src/tools/diagnostics/debug-get-stack.ts | 69 ++++ .../tools/diagnostics/debug-get-variables.ts | 110 ++++++ .../tools/diagnostics/debug-list-sessions.ts | 62 ++++ .../diagnostics/debug-set-breakpoints.ts | 94 +++++ .../tools/diagnostics/debug-start-session.ts | 120 ++++++ .../src/tools/diagnostics/debug-step.ts | 90 +++++ .../tools/diagnostics/debug-wait-for-stop.ts | 113 ++++++ .../b2c-dx-mcp/src/tools/diagnostics/index.ts | 39 ++ .../src/tools/diagnostics/session-registry.ts | 152 ++++++++ packages/b2c-dx-mcp/src/tools/index.ts | 1 + .../src/operations/debug/index.ts | 1 + .../src/operations/debug/resolve-path.ts | 59 +++ skills/b2c-cli/skills/b2c-debug/SKILL.md | 127 +++++++ .../skills/b2c-debug/evals/trigger-evals.json | 16 + 33 files changed, 2585 insertions(+), 8 deletions(-) create mode 100644 .changeset/add-debug-cli-command.md create mode 100644 .changeset/add-debug-diagnostics-toolset.md create mode 100644 .changeset/add-debug-skill-and-docs.md create mode 100644 .changeset/add-mcp-server-context.md create mode 100644 .changeset/add-resolve-breakpoint-path.md create mode 100644 docs/cli/debug.md create mode 100644 packages/b2c-cli/src/commands/debug/cli.ts create mode 100644 packages/b2c-cli/src/utils/debug/repl.ts create mode 100644 packages/b2c-cli/src/utils/debug/rpc.ts create mode 100644 packages/b2c-dx-mcp/src/server-context.ts create mode 100644 packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts create mode 100644 packages/b2c-dx-mcp/src/tools/diagnostics/debug-continue.ts create mode 100644 packages/b2c-dx-mcp/src/tools/diagnostics/debug-end-session.ts create mode 100644 packages/b2c-dx-mcp/src/tools/diagnostics/debug-evaluate.ts create mode 100644 packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-stack.ts create mode 100644 packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-variables.ts create mode 100644 packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts create mode 100644 packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts create mode 100644 packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts create mode 100644 packages/b2c-dx-mcp/src/tools/diagnostics/debug-step.ts create mode 100644 packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts create mode 100644 packages/b2c-dx-mcp/src/tools/diagnostics/index.ts create mode 100644 packages/b2c-dx-mcp/src/tools/diagnostics/session-registry.ts create mode 100644 packages/b2c-tooling-sdk/src/operations/debug/resolve-path.ts create mode 100644 skills/b2c-cli/skills/b2c-debug/SKILL.md create mode 100644 skills/b2c-cli/skills/b2c-debug/evals/trigger-evals.json diff --git a/.changeset/add-debug-cli-command.md b/.changeset/add-debug-cli-command.md new file mode 100644 index 000000000..04007e309 --- /dev/null +++ b/.changeset/add-debug-cli-command.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-cli': minor +--- + +Add `b2c debug cli` command for interactive terminal-based script debugging. Includes a REPL with commands for breakpoints, stepping, variable inspection, and expression evaluation. Use `--rpc` for JSONL-over-stdio mode suitable for headless scripts and agents. diff --git a/.changeset/add-debug-diagnostics-toolset.md b/.changeset/add-debug-diagnostics-toolset.md new file mode 100644 index 000000000..ceb3c920c --- /dev/null +++ b/.changeset/add-debug-diagnostics-toolset.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-dx-mcp': minor +--- + +Add script debugger MCP tools to the CARTRIDGES and STOREFRONTNEXT toolsets. Includes `debug_start_session`, `debug_end_session`, `debug_list_sessions`, `debug_set_breakpoints`, `debug_wait_for_stop`, `debug_get_stack`, `debug_get_variables`, `debug_evaluate`, `debug_continue`, `debug_step_over`, `debug_step_into`, `debug_step_out`, and `debug_capture_at_breakpoint`. diff --git a/.changeset/add-debug-skill-and-docs.md b/.changeset/add-debug-skill-and-docs.md new file mode 100644 index 000000000..764b678b1 --- /dev/null +++ b/.changeset/add-debug-skill-and-docs.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-dx-docs': patch +'@salesforce/b2c-agent-plugins': patch +--- + +Add debug command documentation and b2c-debug agent skill covering interactive REPL, RPC mode, and DAP usage. diff --git a/.changeset/add-mcp-server-context.md b/.changeset/add-mcp-server-context.md new file mode 100644 index 000000000..f23495216 --- /dev/null +++ b/.changeset/add-mcp-server-context.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-dx-mcp': minor +--- + +Add `ServerContext` for persistent server-scoped state across MCP tool invocations. Enables stateful tools (debug sessions, log watches) while preserving per-call config reloading for existing tools. diff --git a/.changeset/add-resolve-breakpoint-path.md b/.changeset/add-resolve-breakpoint-path.md new file mode 100644 index 000000000..bfbd4a11b --- /dev/null +++ b/.changeset/add-resolve-breakpoint-path.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-tooling-sdk': minor +--- + +Add `resolveBreakpointPath` utility that normalizes user-provided file paths to SDAPI script paths. Accepts server paths, absolute/relative local paths, and cartridge-name-prefixed paths with helpful error messages on failure. diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index f60e7954b..e6e21efee 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -106,6 +106,7 @@ const referenceSidebar = [ {text: 'CIP', link: '/cli/cip'}, {text: 'CAP (Commerce Apps)', link: '/cli/cap'}, {text: 'Code', link: '/cli/code'}, + {text: 'Debug', link: '/cli/debug'}, {text: 'Content', link: '/cli/content'}, {text: 'Custom APIs', link: '/cli/custom-apis'}, {text: 'Docs', link: '/cli/docs'}, diff --git a/docs/cli/debug.md b/docs/cli/debug.md new file mode 100644 index 000000000..f513dccd0 --- /dev/null +++ b/docs/cli/debug.md @@ -0,0 +1,250 @@ +--- +description: Commands for interactive script debugging on B2C Commerce instances. +--- + +# Debug Commands + +Commands for connecting to the B2C Commerce Script Debugger API (SDAPI) to set breakpoints, inspect variables, and step through server-side code. + +## Authentication + +All debug commands require **Basic Auth** credentials (username and password) for a Business Manager user with the `WebDAV_Manage_Customization` permission. See [Configuration](/guide/configuration) for details on setting credentials via flags, environment variables, or dw.json. + +The script debugger must also be enabled on the instance: Business Manager > Administration > Development Configuration > Script Debugger > Enable. + +--- + +## b2c debug + +Start a DAP (Debug Adapter Protocol) debug adapter over stdio. This is used by IDE integrations (VS Code, JetBrains) that speak DAP natively. + +### Usage + +```bash +b2c debug +``` + +### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--cartridge-path` | Path to directory containing cartridges | `.` | +| `--client-id` | Client ID for the debugger API | `b2c-cli` | + +See [Global Flags](/cli/#global-flags) for authentication and instance flags. + +### Examples + +```bash +# Start DAP adapter (used by VS Code launch configuration) +b2c debug + +# Specify cartridge directory +b2c debug --cartridge-path ./cartridges +``` + +--- + +## b2c debug cli + +Start an interactive CLI debug session with a REPL interface. Provides a terminal-based debugging experience without requiring a DAP client. + +### Usage + +```bash +b2c debug cli +``` + +### Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--cartridge-path` | Path to directory containing cartridges | `.` | +| `--client-id` | Client ID for the debugger API | `b2c-cli` | +| `--rpc` | Run in RPC mode (JSONL over stdin/stdout) | `false` | + +See [Global Flags](/cli/#global-flags) for authentication and instance flags. + +### REPL Commands + +| Command | Alias | Description | +|---------|-------|-------------| +| `break : [if ]` | `b` | Set breakpoint | +| `breakpoints` | `bl` | List active breakpoints | +| `delete ` | `d` | Delete breakpoint | +| `continue` | `c` | Resume current thread | +| `step` | `s` | Step over | +| `stepin` | `si` | Step into | +| `stepout` | `so` | Step out | +| `stack` | `bt` | Show call stack | +| `frame ` | `f` | Select stack frame | +| `vars` | `v` | Show variables in current frame | +| `members ` | `m` | Expand object members | +| `eval ` | `e` | Evaluate expression | +| `threads` | `t` | List known threads | +| `thread ` | | Switch to thread | +| `help` | `h` | Show commands | +| `quit` | `q` | Disconnect and exit | + +### Examples + +```bash +# Start interactive debugger +b2c debug cli + +# Specify cartridge directory +b2c debug cli --cartridge-path ./cartridges + +# Use a custom client ID (for concurrent sessions) +b2c debug cli --client-id my-session + +# Start in RPC mode for headless scripts +b2c debug cli --rpc +``` + +### Interactive Session Example + +``` +debug> break Cart.js:42 +Breakpoint #1 set at ./cartridges/app_storefront/cartridge/controllers/Cart.js:42 + +debug> break Checkout.js:100 if basket.totalGrossPrice > 100 +Breakpoint #2 set at ./cartridges/app_storefront/cartridge/controllers/Checkout.js:100 + +● Thread 5 halted at ./cartridges/app_storefront/cartridge/controllers/Cart.js:42 in show() + +debug> vars + request: dw.system.Request = [object Request] [local] + basket: dw.order.Basket = [object Basket] [local] + +debug> eval basket.productLineItems.length +3 + +debug> stack + → #0 show ./cartridges/app_storefront/cartridge/controllers/Cart.js:42 + #1 execute /app_storefront/cartridge/controllers/Cart.js:1 + +debug> continue +Thread 5 resumed. +``` + +--- + +## RPC Mode + +When started with `--rpc`, the debug CLI runs as a JSONL-over-stdio RPC server. This enables headless scripts, agents, and other tools to drive the debugger programmatically. + +### Protocol + +- **Input** (stdin): One JSON object per line (JSONL) +- **Output** (stdout): One JSON object per line — either a response or an async event + +### Request Format + +```json +{"id": 1, "command": "set_breakpoints", "args": {"breakpoints": [{"file": "Cart.js", "line": 42}]}} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `id` | number or string | Optional. Echoed back in the response for correlation. | +| `command` | string | Required. The command to execute. | +| `args` | object | Optional. Command-specific arguments. | + +### Response Format + +```json +{"id": 1, "result": {"breakpoints": [{"id": 1, "file": "Cart.js", "line": 42, "script_path": "/app_storefront/cartridge/controllers/Cart.js"}]}} +``` + +On error: + +```json +{"id": 1, "error": "No thread selected. Wait for a thread_stopped event."} +``` + +### Event Format + +Events are emitted asynchronously (not in response to a command): + +```json +{"event": "ready", "data": {}} +{"event": "thread_stopped", "data": {"thread_id": 5, "location": {"file": "Cart.js", "line": 42, "function_name": "show", "script_path": "/app_storefront/cartridge/controllers/Cart.js"}}} +``` + +### Available Commands + +| Command | Args | Description | +|---------|------|-------------| +| `set_breakpoints` | `breakpoints: [{file, line, condition?}]` | Replace all breakpoints | +| `list_breakpoints` | | List current breakpoints | +| `continue` | `thread_id?` | Resume a halted thread | +| `step_over` | `thread_id?` | Step to next line | +| `step_into` | `thread_id?` | Step into function call | +| `step_out` | `thread_id?` | Step out of function | +| `get_stack` | `thread_id?` | Get call stack frames | +| `get_variables` | `thread_id?, frame_index?, scope?, object_path?` | Get variables | +| `evaluate` | `expression, thread_id?, frame_index?` | Evaluate expression | +| `list_threads` | | List known threads | +| `select_thread` | `thread_id` | Switch current thread | +| `select_frame` | `index` | Switch current frame | + +When `thread_id` is omitted, the last thread that halted is used. + +### Events + +| Event | Description | +|-------|-------------| +| `ready` | Emitted once after connection is established | +| `thread_stopped` | A thread hit a breakpoint or step completed | + +### Example Session (Python) + +```python +import subprocess, json + +proc = subprocess.Popen( + ["b2c", "debug", "cli", "--rpc"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + text=True, bufsize=1 +) + +def send(cmd, args=None, id=None): + msg = {"command": cmd} + if args: msg["args"] = args + if id is not None: msg["id"] = id + proc.stdin.write(json.dumps(msg) + "\n") + proc.stdin.flush() + +def recv(): + return json.loads(proc.stdout.readline()) + +# Wait for ready +assert recv()["event"] == "ready" + +# Set a breakpoint +send("set_breakpoints", {"breakpoints": [{"file": "Cart.js", "line": 42}]}, id=1) +response = recv() # {"id": 1, "result": {...}} + +# Wait for breakpoint hit (trigger a request on the instance) +event = recv() # {"event": "thread_stopped", "data": {...}} + +# Inspect state +send("get_stack", id=2) +stack = recv() + +send("get_variables", id=3) +variables = recv() + +# Continue execution +send("continue", id=4) +recv() +``` + +--- + +## See Also + +- [Configuration](/guide/configuration) - Setting up instance credentials +- [Logs Commands](/cli/logs) - Retrieving server logs for debugging +- [Code Commands](/cli/code) - Deploying code before debugging diff --git a/packages/b2c-cli/src/commands/debug/cli.ts b/packages/b2c-cli/src/commands/debug/cli.ts new file mode 100644 index 000000000..0f14122e2 --- /dev/null +++ b/packages/b2c-cli/src/commands/debug/cli.ts @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {Flags} from '@oclif/core'; +import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {findCartridges} from '@salesforce/b2c-tooling-sdk/operations/code'; +import { + DebugSessionManager, + createSourceMapper, + type DebugSessionCallbacks, +} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {DebugRepl} from '../../utils/debug/repl.js'; +import {DebugRpc} from '../../utils/debug/rpc.js'; + +export default class DebugCli extends InstanceCommand { + static description = + 'Start an interactive CLI debug session for B2C Commerce script debugging. ' + + 'Use --rpc for JSONL-over-stdio RPC mode suitable for headless scripts and agents.'; + + static examples = [ + '<%= config.bin %> debug cli', + '<%= config.bin %> debug cli --cartridge-path ./cartridges', + '<%= config.bin %> debug cli --client-id my-debugger', + '<%= config.bin %> debug cli --rpc', + ]; + + static flags = { + ...InstanceCommand.baseFlags, + 'cartridge-path': Flags.string({ + description: 'Path to cartridges directory', + default: '.', + }), + 'client-id': Flags.string({ + description: 'Client ID for the debugger API', + default: 'b2c-cli', + }), + rpc: Flags.boolean({ + description: 'Run in RPC mode: JSONL commands on stdin, JSONL responses on stdout', + default: false, + }), + }; + + async run(): Promise { + this.requireServer(); + + const hostname = this.resolvedConfig.values.hostname!; + const username = this.resolvedConfig.values.username; + const password = this.resolvedConfig.values.password; + + if (!username || !password) { + this.error( + 'Basic auth credentials (username/password) are required for the script debugger. ' + + 'Set via --username/--password flags, SFCC_USERNAME/SFCC_PASSWORD env vars, or dw.json.', + ); + } + + const cartridgePath = this.flags['cartridge-path'] ?? '.'; + const cartridges = findCartridges(cartridgePath); + if (cartridges.length === 0) { + this.warn(`No cartridges found in ${cartridgePath}`); + } + + this.logger.info( + `Mapped ${cartridges.length} cartridge(s): ${cartridges.map((c) => c.name).join(', ') || '(none)'}`, + ); + + const sourceMapper = createSourceMapper(cartridges); + + const isRpc = this.flags.rpc; + const holder: {handler?: DebugRepl | DebugRpc} = {}; + + const callbacks: DebugSessionCallbacks = { + onConnected: (host) => this.logger.debug(`Connected to script debugger on ${host}`), + onDisconnected: () => this.logger.debug('Script debugger disconnected'), + onDebuggerDisabled: () => this.logger.debug('Script debugger was disabled externally'), + onThreadStopped(thread) { + holder.handler?.onThreadStopped(thread); + }, + }; + + const manager = new DebugSessionManager( + { + hostname, + username, + password, + clientId: this.flags['client-id'], + cartridgeRoots: cartridges, + }, + callbacks, + ); + + await manager.connect(); + + if (isRpc) { + const rpc = new DebugRpc({ + manager, + sourceMapper, + cartridges, + output: process.stdout, + input: process.stdin, + }); + holder.handler = rpc; + + try { + await rpc.run(); + } finally { + await manager.disconnect(); + } + } else { + const repl = new DebugRepl({ + manager, + sourceMapper, + cartridges, + output: process.stderr, + input: process.stdin, + }); + holder.handler = repl; + + try { + await repl.run(); + } finally { + await manager.disconnect(); + } + } + } +} diff --git a/packages/b2c-cli/src/utils/debug/repl.ts b/packages/b2c-cli/src/utils/debug/repl.ts new file mode 100644 index 000000000..c7a948e33 --- /dev/null +++ b/packages/b2c-cli/src/utils/debug/repl.ts @@ -0,0 +1,350 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import readline from 'node:readline'; +import type { + DebugSessionManager, + SourceMapper, + SdapiBreakpoint, + SdapiScriptThread, + SdapiObjectMember, +} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import type {CartridgeMapping} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {resolveBreakpointPath} from '@salesforce/b2c-tooling-sdk/operations/debug'; + +const RED = ''; +const YELLOW = ''; +const CYAN = ''; +const GREEN = ''; +const DIM = ''; +const BOLD = ''; +const RESET = ''; + +interface ReplState { + currentThreadId?: number; + currentFrameIndex: number; +} + +export interface DebugReplOptions { + cartridges: CartridgeMapping[]; + manager: DebugSessionManager; + sourceMapper: SourceMapper; + output: NodeJS.WritableStream; + input: NodeJS.ReadableStream; +} + +type CommandHandler = (args: string) => Promise; + +export class DebugRepl { + private breakpoints: SdapiBreakpoint[] = []; + private readonly cartridges: CartridgeMapping[]; + private closed = false; + private commands: Map = new Map(); + private readonly input: NodeJS.ReadableStream; + private readonly manager: DebugSessionManager; + private readonly output: NodeJS.WritableStream; + private resolveRun?: () => void; + private rl?: readline.Interface; + private readonly sourceMapper: SourceMapper; + private readonly state: ReplState = {currentFrameIndex: 0}; + + constructor(options: DebugReplOptions) { + this.manager = options.manager; + this.sourceMapper = options.sourceMapper; + this.cartridges = options.cartridges; + this.output = options.output; + this.input = options.input; + this.registerCommands(); + } + + onThreadStopped(thread: SdapiScriptThread): void { + this.state.currentThreadId = thread.id; + this.state.currentFrameIndex = 0; + + const topFrame = thread.call_stack?.[0]; + if (topFrame) { + const loc = topFrame.location; + const localPath = this.sourceMapper.toLocalPath(loc.script_path); + const displayPath = localPath ?? loc.script_path; + const fn = loc.function_name || ''; + this.print( + `\n${YELLOW}${BOLD}● Thread ${thread.id}${RESET} halted at ${CYAN}${displayPath}:${loc.line_number}${RESET} in ${fn}()`, + ); + } else { + this.print(`\n${YELLOW}${BOLD}● Thread ${thread.id}${RESET} halted`); + } + } + + async run(): Promise { + this.closed = false; + this.rl = readline.createInterface({ + input: this.input, + output: this.output, + prompt: `${DIM}debug>${RESET} `, + }); + + this.print(`${GREEN}Script debugger connected.${RESET} Type ${BOLD}help${RESET} for available commands.`); + this.rl.prompt(); + + this.rl.on('line', (line: string) => { + const trimmed = line.trim(); + if (!trimmed) { + if (!this.closed) this.rl?.prompt(); + return; + } + this.handleLine(trimmed) + .then(() => { + if (!this.closed) this.rl?.prompt(); + }) + .catch(() => { + if (!this.closed) this.rl?.prompt(); + }); + }); + + this.rl.on('close', () => { + this.closed = true; + this.resolveRun?.(); + }); + + return new Promise((resolve) => { + this.resolveRun = resolve; + }); + } + + private async handleLine(line: string): Promise { + const spaceIdx = line.indexOf(' '); + const cmd = spaceIdx === -1 ? line : line.slice(0, spaceIdx); + const args = spaceIdx === -1 ? '' : line.slice(spaceIdx + 1).trim(); + + const handler = this.commands.get(cmd); + if (!handler) { + this.print(`${RED}Unknown command: ${cmd}${RESET}. Type ${BOLD}help${RESET} for available commands.`); + return; + } + + try { + await handler(args); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + this.print(`${RED}Error: ${msg}${RESET}`); + } + } + + private print(text: string): void { + this.output.write(text + '\n'); + } + + private printVariables(members: SdapiObjectMember[]): void { + if (members.length === 0) { + this.print(' (no variables)'); + return; + } + for (const m of members) { + const scope = m.scope ? ` ${DIM}[${m.scope}]${RESET}` : ''; + const value = m.value.length > 200 ? m.value.slice(0, 200) + '...' : m.value; + this.print(` ${BOLD}${m.name}${RESET}: ${DIM}${m.type}${RESET} = ${value}${scope}`); + } + } + + private registerCommand(name: string, shortcut: string, handler: CommandHandler): void { + this.commands.set(name, handler); + if (shortcut) this.commands.set(shortcut, handler); + } + + private registerCommands(): void { + this.registerCommand('break', 'b', async (args) => { + const match = args.match(/^(.+):(\d+)(?:\s+if\s+(.+))?$/); + if (!match) { + this.print('Usage: break : [if ]'); + return; + } + const [, file, lineStr, condition] = match; + const line = Number.parseInt(lineStr, 10); + const scriptPath = resolveBreakpointPath(file, this.sourceMapper, this.cartridges); + + const input = {script_path: scriptPath, line_number: line, condition}; + const existingInputs = this.breakpoints.map((bp) => ({ + script_path: bp.script_path, + line_number: bp.line_number, + condition: bp.condition, + })); + this.breakpoints = await this.manager.setBreakpoints([...existingInputs, input]); + const added = this.breakpoints.at(-1); + if (added) { + const local = this.sourceMapper.toLocalPath(added.script_path); + this.print( + `Breakpoint #${added.id} set at ${CYAN}${local ?? added.script_path}:${added.line_number}${RESET}` + + (added.condition ? ` ${DIM}if ${added.condition}${RESET}` : ''), + ); + } + }); + + this.registerCommand('breakpoints', 'bl', async () => { + if (this.breakpoints.length === 0) { + this.print('No breakpoints set.'); + return; + } + for (const bp of this.breakpoints) { + const local = this.sourceMapper.toLocalPath(bp.script_path); + this.print( + ` #${bp.id} ${CYAN}${local ?? bp.script_path}:${bp.line_number}${RESET}` + + (bp.condition ? ` ${DIM}if ${bp.condition}${RESET}` : ''), + ); + } + }); + + this.registerCommand('delete', 'd', async (args) => { + const id = Number.parseInt(args, 10); + if (Number.isNaN(id)) { + this.print('Usage: delete '); + return; + } + const remaining = this.breakpoints.filter((bp) => bp.id !== id); + if (remaining.length === this.breakpoints.length) { + this.print(`${RED}No breakpoint with id ${id}${RESET}`); + return; + } + const inputs = remaining.map((bp) => ({ + script_path: bp.script_path, + line_number: bp.line_number, + condition: bp.condition, + })); + this.breakpoints = await this.manager.setBreakpoints(inputs); + this.print(`Breakpoint #${id} deleted.`); + }); + + this.registerCommand('continue', 'c', async () => { + const threadId = this.requireThread(); + await this.manager.resume(threadId); + this.print(`Thread ${threadId} resumed.`); + }); + + this.registerCommand('step', 's', async () => { + const threadId = this.requireThread(); + await this.manager.stepOver(threadId); + this.print(`Thread ${threadId} stepping over...`); + }); + + this.registerCommand('stepin', 'si', async () => { + const threadId = this.requireThread(); + await this.manager.stepInto(threadId); + this.print(`Thread ${threadId} stepping into...`); + }); + + this.registerCommand('stepout', 'so', async () => { + const threadId = this.requireThread(); + await this.manager.stepOut(threadId); + this.print(`Thread ${threadId} stepping out...`); + }); + + this.registerCommand('stack', 'bt', async () => { + const threadId = this.requireThread(); + const thread = await this.manager.client.getThread(threadId); + for (const frame of thread.call_stack) { + const loc = frame.location; + const local = this.sourceMapper.toLocalPath(loc.script_path); + const marker = frame.index === this.state.currentFrameIndex ? '→' : ' '; + this.print( + ` ${marker} #${frame.index} ${loc.function_name || ''} ${CYAN}${local ?? loc.script_path}:${loc.line_number}${RESET}`, + ); + } + }); + + this.registerCommand('frame', 'f', async (args) => { + const idx = Number.parseInt(args, 10); + if (Number.isNaN(idx) || idx < 0) { + this.print('Usage: frame '); + return; + } + this.state.currentFrameIndex = idx; + this.print(`Switched to frame #${idx}`); + }); + + this.registerCommand('vars', 'v', async () => { + const threadId = this.requireThread(); + const result = await this.manager.client.getVariables(threadId, this.state.currentFrameIndex); + this.printVariables(result.object_members); + }); + + this.registerCommand('members', 'm', async (args) => { + if (!args) { + this.print('Usage: members '); + return; + } + const threadId = this.requireThread(); + const result = await this.manager.client.getMembers(threadId, this.state.currentFrameIndex, args); + this.printVariables(result.object_members); + }); + + this.registerCommand('eval', 'e', async (args) => { + if (!args) { + this.print('Usage: eval '); + return; + } + const threadId = this.requireThread(); + const result = await this.manager.client.evaluate(threadId, this.state.currentFrameIndex, args); + this.print(result.result); + }); + + this.registerCommand('threads', 't', async () => { + const threads = this.manager.getKnownThreads(); + if (threads.length === 0) { + this.print('No active threads.'); + return; + } + for (const thread of threads) { + const marker = thread.id === this.state.currentThreadId ? '→' : ' '; + const status = thread.status === 'halted' ? `${YELLOW}halted${RESET}` : 'running'; + const topFrame = thread.call_stack?.[0]; + const location = topFrame + ? ` at ${CYAN}${this.sourceMapper.toLocalPath(topFrame.location.script_path) ?? topFrame.location.script_path}:${topFrame.location.line_number}${RESET}` + : ''; + this.print(` ${marker} Thread ${thread.id} ${status}${location}`); + } + }); + + this.commands.set('thread', async (args) => { + const id = Number.parseInt(args, 10); + if (Number.isNaN(id)) { + this.print('Usage: thread '); + return; + } + this.state.currentThreadId = id; + this.state.currentFrameIndex = 0; + this.print(`Switched to thread ${id}`); + }); + + this.registerCommand('help', 'h', async () => { + this.print(`${BOLD}Available commands:${RESET}`); + this.print(` ${BOLD}break${RESET} (b) : [if ] Set breakpoint`); + this.print(` ${BOLD}breakpoints${RESET} (bl) List breakpoints`); + this.print(` ${BOLD}delete${RESET} (d) Delete breakpoint`); + this.print(` ${BOLD}continue${RESET} (c) Resume thread`); + this.print(` ${BOLD}step${RESET} (s) Step over`); + this.print(` ${BOLD}stepin${RESET} (si) Step into`); + this.print(` ${BOLD}stepout${RESET} (so) Step out`); + this.print(` ${BOLD}stack${RESET} (bt) Show call stack`); + this.print(` ${BOLD}frame${RESET} (f) Select frame`); + this.print(` ${BOLD}vars${RESET} (v) Show variables`); + this.print(` ${BOLD}members${RESET} (m) Expand object`); + this.print(` ${BOLD}eval${RESET} (e) Evaluate expression`); + this.print(` ${BOLD}threads${RESET} (t) List threads`); + this.print(` ${BOLD}thread${RESET} Switch to thread`); + this.print(` ${BOLD}quit${RESET} (q) Disconnect and exit`); + }); + + this.registerCommand('quit', 'q', async () => { + this.rl?.close(); + }); + } + + private requireThread(): number { + if (this.state.currentThreadId === undefined) { + throw new Error('No thread selected. Wait for a thread to halt at a breakpoint.'); + } + return this.state.currentThreadId; + } +} diff --git a/packages/b2c-cli/src/utils/debug/rpc.ts b/packages/b2c-cli/src/utils/debug/rpc.ts new file mode 100644 index 000000000..339ad84c8 --- /dev/null +++ b/packages/b2c-cli/src/utils/debug/rpc.ts @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import readline from 'node:readline'; +import type { + BreakpointInput, + DebugSessionManager, + SdapiScriptThread, + SourceMapper, +} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import type {CartridgeMapping} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {resolveBreakpointPath} from '@salesforce/b2c-tooling-sdk/operations/debug'; + +const MAX_VALUE_LENGTH = 200; + +interface RpcRequest { + id?: number | string; + command: string; + args?: Record; +} + +interface RpcResponse { + id?: number | string; + result?: unknown; + error?: string; +} + +interface RpcEvent { + event: string; + data: unknown; +} + +export interface DebugRpcOptions { + cartridges: CartridgeMapping[]; + input: NodeJS.ReadableStream; + manager: DebugSessionManager; + output: NodeJS.WritableStream; + sourceMapper: SourceMapper; +} + +export class DebugRpc { + private readonly cartridges: CartridgeMapping[]; + private currentFrameIndex = 0; + private currentThreadId?: number; + private readonly input: NodeJS.ReadableStream; + private readonly manager: DebugSessionManager; + private readonly output: NodeJS.WritableStream; + private resolveRun?: () => void; + private readonly sourceMapper: SourceMapper; + + constructor(options: DebugRpcOptions) { + this.manager = options.manager; + this.sourceMapper = options.sourceMapper; + this.cartridges = options.cartridges; + this.output = options.output; + this.input = options.input; + } + + onThreadStopped(thread: SdapiScriptThread): void { + this.currentThreadId = thread.id; + this.currentFrameIndex = 0; + + const topFrame = thread.call_stack?.[0]; + const loc = topFrame?.location; + this.emitEvent('thread_stopped', { + thread_id: thread.id, + location: loc + ? { + file: this.sourceMapper.toLocalPath(loc.script_path) ?? null, + line: loc.line_number, + function_name: loc.function_name, + script_path: loc.script_path, + } + : null, + }); + } + + async run(): Promise { + const rl = readline.createInterface({input: this.input, terminal: false}); + + this.emitEvent('ready', {}); + + rl.on('line', (line: string) => { + const trimmed = line.trim(); + if (!trimmed) return; + this.handleMessage(trimmed).catch(() => {}); + }); + + rl.on('close', () => { + this.resolveRun?.(); + }); + + return new Promise((resolve) => { + this.resolveRun = resolve; + }); + } + + private emitEvent(event: string, data: unknown): void { + const msg: RpcEvent = {event, data}; + this.output.write(JSON.stringify(msg) + '\n'); + } + + private getThread(): number { + if (this.currentThreadId === undefined) { + throw new Error('No thread selected. Wait for a thread_stopped event.'); + } + return this.currentThreadId; + } + + private async handleCommand(command: string, args: Record): Promise { + switch (command) { + case 'continue': { + const threadId = (args.thread_id as number) ?? this.getThread(); + await this.manager.resume(threadId); + return {thread_id: threadId, status: 'resumed'}; + } + + case 'evaluate': { + const threadId = (args.thread_id as number) ?? this.getThread(); + const frameIndex = (args.frame_index as number) ?? this.currentFrameIndex; + const expression = args.expression as string; + if (!expression) throw new Error('Missing required arg: expression'); + const evalResult = await this.manager.client.evaluate(threadId, frameIndex, expression); + return {expression: evalResult.expression, result: evalResult.result}; + } + + case 'get_stack': { + const threadId = (args.thread_id as number) ?? this.getThread(); + const thread = await this.manager.client.getThread(threadId); + return { + thread_id: thread.id, + frames: thread.call_stack.map((frame) => ({ + index: frame.index, + function_name: frame.location.function_name, + file: this.sourceMapper.toLocalPath(frame.location.script_path) ?? null, + line: frame.location.line_number, + script_path: frame.location.script_path, + })), + }; + } + + case 'get_variables': { + const threadId = (args.thread_id as number) ?? this.getThread(); + const frameIndex = (args.frame_index as number) ?? this.currentFrameIndex; + const objectPath = args.object_path as string | undefined; + + if (objectPath) { + const result = await this.manager.client.getMembers(threadId, frameIndex, objectPath); + return { + variables: result.object_members.map((m) => ({ + name: m.name, + type: m.type, + value: truncateValue(m.value), + has_children: !isPrimitive(m.type), + })), + }; + } + + const result = await this.manager.client.getVariables(threadId, frameIndex); + let members = result.object_members; + if (args.scope) { + members = members.filter((m) => m.scope === args.scope); + } + return { + variables: members.map((m) => ({ + name: m.name, + type: m.type, + value: truncateValue(m.value), + scope: m.scope, + has_children: !isPrimitive(m.type), + })), + }; + } + + case 'list_breakpoints': { + const bps = await this.manager.client.getBreakpoints(); + return { + breakpoints: bps.map((bp) => ({ + id: bp.id, + file: this.sourceMapper.toLocalPath(bp.script_path) ?? null, + line: bp.line_number, + script_path: bp.script_path, + condition: bp.condition, + })), + }; + } + + case 'list_threads': { + const threads = this.manager.getKnownThreads(); + return { + threads: threads.map((t) => { + const topFrame = t.call_stack?.[0]; + const loc = topFrame?.location; + return { + thread_id: t.id, + status: t.status, + current: t.id === this.currentThreadId, + location: loc + ? { + file: this.sourceMapper.toLocalPath(loc.script_path) ?? null, + line: loc.line_number, + function_name: loc.function_name, + script_path: loc.script_path, + } + : null, + }; + }), + }; + } + + case 'select_frame': { + const index = args.index as number; + if (index === undefined || index < 0) throw new Error('Missing required arg: index'); + this.currentFrameIndex = index; + return {frame_index: index}; + } + + case 'select_thread': { + const id = args.thread_id as number; + if (id === undefined) throw new Error('Missing required arg: thread_id'); + this.currentThreadId = id; + this.currentFrameIndex = 0; + return {thread_id: id}; + } + + case 'set_breakpoints': { + const breakpoints = args.breakpoints as Array<{file: string; line: number; condition?: string}>; + if (!breakpoints) throw new Error('Missing required arg: breakpoints'); + + const inputs: BreakpointInput[] = breakpoints.map((bp) => ({ + script_path: resolveBreakpointPath(bp.file, this.sourceMapper, this.cartridges), + line_number: bp.line, + condition: bp.condition, + })); + + const result = await this.manager.setBreakpoints(inputs); + return { + breakpoints: result.map((bp) => ({ + id: bp.id, + file: this.sourceMapper.toLocalPath(bp.script_path) ?? null, + line: bp.line_number, + script_path: bp.script_path, + condition: bp.condition, + })), + }; + } + + case 'step_into': { + const threadId = (args.thread_id as number) ?? this.getThread(); + await this.manager.stepInto(threadId); + return {thread_id: threadId, action: 'step_into'}; + } + + case 'step_out': { + const threadId = (args.thread_id as number) ?? this.getThread(); + await this.manager.stepOut(threadId); + return {thread_id: threadId, action: 'step_out'}; + } + + case 'step_over': { + const threadId = (args.thread_id as number) ?? this.getThread(); + await this.manager.stepOver(threadId); + return {thread_id: threadId, action: 'step_over'}; + } + + default: { + throw new Error(`Unknown command: ${command}`); + } + } + } + + private async handleMessage(line: string): Promise { + let request: RpcRequest; + try { + request = JSON.parse(line) as RpcRequest; + } catch { + this.sendResponse({error: 'Invalid JSON'}); + return; + } + + if (!request.command) { + this.sendResponse({id: request.id, error: 'Missing "command" field'}); + return; + } + + try { + const result = await this.handleCommand(request.command, request.args ?? {}); + this.sendResponse({id: request.id, result}); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.sendResponse({id: request.id, error: message}); + } + } + + private sendResponse(response: RpcResponse): void { + this.output.write(JSON.stringify(response) + '\n'); + } +} + +function isPrimitive(type: string): boolean { + return ['boolean', 'Boolean', 'null', 'number', 'Number', 'string', 'String', 'undefined'].includes(type); +} + +function truncateValue(value: string): string { + if (value.length <= MAX_VALUE_LENGTH) return value; + return value.slice(0, MAX_VALUE_LENGTH) + '...'; +} diff --git a/packages/b2c-dx-mcp/src/commands/mcp.ts b/packages/b2c-dx-mcp/src/commands/mcp.ts index 990b17e5a..170ee2d48 100644 --- a/packages/b2c-dx-mcp/src/commands/mcp.ts +++ b/packages/b2c-dx-mcp/src/commands/mcp.ts @@ -148,6 +148,7 @@ import type {ResolvedB2CConfig} from '@salesforce/b2c-tooling-sdk/config'; import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js'; import {B2CDxMcpServer} from '../server.js'; import {Services} from '../services.js'; +import {ServerContext} from '../server-context.js'; import {registerToolsets} from '../registry.js'; import {TOOLSETS, type StartupFlags} from '../utils/index.js'; @@ -219,6 +220,9 @@ export default class McpServerCommand extends BaseCommand { const sendStopAndResolve = (signal: string): void => { this.shutdownSignal = signal; - this.telemetry?.sendEvent('SERVER_STOPPED', {signal}); - // Flush telemetry before resolving to ensure SERVER_STOPPED is sent - // before finally() proceeds to stop telemetry - const flushPromise = this.telemetry?.flush() ?? Promise.resolve(); - flushPromise.then(() => resolve()).catch(() => resolve()); + const cleanup = this.serverContext?.destroyAll() ?? Promise.resolve(); + cleanup + .catch(() => {}) + .then(() => { + this.telemetry?.sendEvent('SERVER_STOPPED', {signal}); + const flushPromise = this.telemetry?.flush() ?? Promise.resolve(); + return flushPromise; + }) + .then(() => resolve()) + .catch(() => resolve()); }; // Handle stdin close (MCP client disconnects normally) diff --git a/packages/b2c-dx-mcp/src/registry.ts b/packages/b2c-dx-mcp/src/registry.ts index 737099cb5..3d7147f04 100644 --- a/packages/b2c-dx-mcp/src/registry.ts +++ b/packages/b2c-dx-mcp/src/registry.ts @@ -10,7 +10,9 @@ import type {McpTool, Toolset, StartupFlags} from './utils/index.js'; import {ALL_TOOLSETS, TOOLSETS, VALID_TOOLSET_NAMES} from './utils/index.js'; import type {B2CDxMcpServer} from './server.js'; import type {Services} from './services.js'; +import type {ServerContext} from './server-context.js'; import {createCartridgesTools} from './tools/cartridges/index.js'; +import {createDiagnosticsTools} from './tools/diagnostics/index.js'; import {createMrtTools} from './tools/mrt/index.js'; import {createPwav3Tools} from './tools/pwav3/index.js'; import {createScapiTools} from './tools/scapi/index.js'; @@ -79,7 +81,10 @@ export type ToolRegistry = Record; * @param loadServices - Function that loads configuration and returns Services instance * @returns Complete tool registry */ -export function createToolRegistry(loadServices: () => Promise | Services): ToolRegistry { +export function createToolRegistry( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): ToolRegistry { const registry: ToolRegistry = { CARTRIDGES: [], MRT: [], @@ -91,6 +96,7 @@ export function createToolRegistry(loadServices: () => Promise | Servi // Collect all tools from all factories const allTools: McpTool[] = [ ...createCartridgesTools(loadServices), + ...createDiagnosticsTools(loadServices, serverContext), ...createMrtTools(loadServices), ...createPwav3Tools(loadServices), ...createScapiTools(loadServices), @@ -173,6 +179,7 @@ export async function registerToolsets( flags: StartupFlags, server: B2CDxMcpServer, loadServices: () => Promise | Services, + serverContext?: ServerContext, ): Promise { const toolsets = flags.toolsets ?? []; const individualTools = flags.tools ?? []; @@ -180,7 +187,7 @@ export async function registerToolsets( const logger = getLogger(); // Create the tool registry (all available tools) - const toolRegistry = createToolRegistry(loadServices); + const toolRegistry = createToolRegistry(loadServices, serverContext); // Build flat list of all tools for lookup const allTools = Object.values(toolRegistry).flat(); diff --git a/packages/b2c-dx-mcp/src/server-context.ts b/packages/b2c-dx-mcp/src/server-context.ts new file mode 100644 index 000000000..ef882bdd1 --- /dev/null +++ b/packages/b2c-dx-mcp/src/server-context.ts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {DebugSessionRegistry} from './tools/diagnostics/session-registry.js'; + +export class ServerContext { + readonly debugSessions: DebugSessionRegistry; + + constructor() { + this.debugSessions = new DebugSessionRegistry(); + } + + async destroyAll(): Promise { + await this.debugSessions.destroyAll(); + } +} diff --git a/packages/b2c-dx-mcp/src/services.ts b/packages/b2c-dx-mcp/src/services.ts index b659e51b2..1815e1b2d 100644 --- a/packages/b2c-dx-mcp/src/services.ts +++ b/packages/b2c-dx-mcp/src/services.ts @@ -175,6 +175,16 @@ export class Services { return fs.existsSync(targetPath); } + /** + * Get Basic auth credentials for SDAPI operations (script debugger). + * Returns undefined if credentials are not configured. + */ + public getBasicAuthCredentials(): undefined | {hostname: string; username: string; password: string} { + const {hostname, username, password} = this.resolvedConfig.values; + if (!hostname || !username || !password) return undefined; + return {hostname, username, password}; + } + /** * Get Custom APIs client for managing custom SCAPI endpoints. * Requires shortCode, tenantId, and OAuth credentials to be configured. diff --git a/packages/b2c-dx-mcp/src/tools/adapter.ts b/packages/b2c-dx-mcp/src/tools/adapter.ts index a3d50c7da..7daded4a2 100644 --- a/packages/b2c-dx-mcp/src/tools/adapter.ts +++ b/packages/b2c-dx-mcp/src/tools/adapter.ts @@ -75,6 +75,7 @@ import {z, type ZodRawShape, type ZodObject, type ZodType} from 'zod'; import type {B2CInstance} from '@salesforce/b2c-tooling-sdk'; import type {McpTool, ToolResult, Toolset} from '../utils/index.js'; import type {Services, MrtConfig} from '../services.js'; +import type {ServerContext} from '../server-context.js'; /** * Context provided to tool execute functions. @@ -99,6 +100,12 @@ export interface ToolExecutionContext { * Services instance for file system access and other utilities. */ services: Services; + + /** + * Server-scoped persistent state (debug sessions, log watches, etc.). + * Created once at server startup and shared across all tool invocations. + */ + serverContext?: ServerContext; } /** @@ -255,6 +262,7 @@ function formatZodErrors(error: z.ZodError): string { export function createToolAdapter( options: ToolAdapterOptions, loadServices: () => Promise | Services, + serverContext?: ServerContext, ): McpTool { const { name, @@ -322,6 +330,7 @@ export function createToolAdapter( b2cInstance, mrtConfig, services, + serverContext, }; const output = await execute(args, context); diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts new file mode 100644 index 000000000..774959796 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; +import { + resolveBreakpointPath, + type BreakpointInput, + type SdapiScriptThread, +} from '@salesforce/b2c-tooling-sdk/operations/debug'; + +const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_TIMEOUT_MS = 120_000; +const MAX_VALUE_LENGTH = 200; +const PRIMITIVE_TYPES = new Set(['boolean', 'Boolean', 'null', 'number', 'Number', 'string', 'String', 'undefined']); + +interface CaptureInput { + session_id: string; + file: string; + line: number; + condition?: string; + expressions?: string[]; + timeout_ms?: number; + auto_continue?: boolean; +} + +interface CaptureOutput { + breakpoint: { + file: null | string; + line: number; + script_path: string; + }; + halted: boolean; + timed_out?: boolean; + thread_id?: number; + stack?: Array<{ + index: number; + function_name: string; + file: null | string; + line: number; + script_path: string; + }>; + variables?: Array<{ + name: string; + type: string; + value: string; + scope?: string; + has_children: boolean; + }>; + evaluations?: Array<{ + expression: string; + result: string; + }>; + auto_continued: boolean; +} + +export function createDebugCaptureAtBreakpointTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_capture_at_breakpoint', + description: + 'Set a breakpoint, wait for it to be hit, and capture a diagnostic snapshot (stack, variables, expression results). ' + + 'Optionally resumes the thread after capture. ' + + 'This is a higher-level tool that combines debug_set_breakpoints, debug_wait_for_stop, debug_get_stack, debug_get_variables, and debug_evaluate.', + toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + file: z.string().describe('Local file path or server script path for the breakpoint.'), + line: z.number().int().positive().describe('Line number for the breakpoint.'), + condition: z.string().optional().describe('Optional conditional expression for the breakpoint.'), + expressions: z.array(z.string()).optional().describe('Expressions to evaluate when the breakpoint is hit.'), + timeout_ms: z + .number() + .int() + .positive() + .max(MAX_TIMEOUT_MS) + .optional() + .describe( + `Timeout in milliseconds waiting for the breakpoint to be hit (default: ${DEFAULT_TIMEOUT_MS}, max: ${MAX_TIMEOUT_MS}).`, + ), + auto_continue: z + .boolean() + .optional() + .describe('If true, resume the thread after capturing the snapshot. Defaults to false.'), + }, + async execute(args, context) { + const registry = context.serverContext?.debugSessions; + if (!registry) { + throw new Error('Debug session registry not available'); + } + + const entry = registry.getSessionOrThrow(args.session_id); + const timeout = Math.min(args.timeout_ms ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS); + + const scriptPath = resolveBreakpointPath(args.file, entry.sourceMapper, entry.cartridges); + + // Set breakpoint (adds to existing) + const bpInput: BreakpointInput = { + script_path: scriptPath, + line_number: args.line, + condition: args.condition, + }; + + const existingBps = entry.breakpoints.map((bp) => ({ + script_path: bp.script_path, + line_number: bp.line_number, + condition: bp.condition, + })); + const allBps = [...existingBps, bpInput]; + const bpResult = await entry.manager.setBreakpoints(allBps); + entry.breakpoints = bpResult; + + const breakpointInfo = { + file: entry.sourceMapper.toLocalPath(scriptPath) ?? null, + line: args.line, + script_path: scriptPath, + }; + + // Wait for halt + const haltedThread = entry.manager.getKnownThreads().find((t) => t.status === 'halted'); + let thread: null | SdapiScriptThread = haltedThread ?? null; + + if (!thread) { + thread = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = entry.haltWaiters.findIndex((w) => w.timer === timer); + if (idx !== -1) entry.haltWaiters.splice(idx, 1); + resolve(null); + }, timeout); + entry.haltWaiters.push({resolve: (t) => resolve(t), reject, timer}); + }); + } + + if (!thread) { + return { + breakpoint: breakpointInfo, + halted: false, + timed_out: true, + auto_continued: false, + }; + } + + // Capture stack + const threadDetail = await entry.manager.client.getThread(thread.id); + const stack = threadDetail.call_stack.map((frame) => ({ + index: frame.index, + function_name: frame.location.function_name, + file: entry.sourceMapper.toLocalPath(frame.location.script_path) ?? null, + line: frame.location.line_number, + script_path: frame.location.script_path, + })); + + // Capture variables (top frame) + const varsResult = await entry.manager.client.getVariables(thread.id, 0); + const variables = varsResult.object_members.map((m) => ({ + name: m.name, + type: m.type, + value: m.value.length > MAX_VALUE_LENGTH ? m.value.slice(0, MAX_VALUE_LENGTH) + '...' : m.value, + scope: m.scope, + has_children: !PRIMITIVE_TYPES.has(m.type), + })); + + // Evaluate expressions (sequential — each must complete before the next on the debugger) + const evaluations: Array<{expression: string; result: string}> = []; + if (args.expressions) { + for (const expr of args.expressions) { + try { + // eslint-disable-next-line no-await-in-loop -- sequential: each eval must complete before the next + const evalResult = await entry.manager.client.evaluate(thread.id, 0, expr); + evaluations.push({expression: evalResult.expression, result: evalResult.result}); + } catch (error) { + evaluations.push({ + expression: expr, + result: `Error: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + } + + // Optionally continue + let autoContinued = false; + if (args.auto_continue) { + await entry.manager.resume(thread.id); + autoContinued = true; + } + + return { + breakpoint: breakpointInfo, + halted: true, + thread_id: thread.id, + stack, + variables, + evaluations: evaluations.length > 0 ? evaluations : undefined, + auto_continued: autoContinued, + }; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-continue.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-continue.ts new file mode 100644 index 000000000..7c1009819 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-continue.ts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; + +interface ContinueInput { + session_id: string; + thread_id: number; +} + +interface ContinueOutput { + thread_id: number; + status: string; +} + +export function createDebugContinueTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_continue', + description: + 'Resume execution of a halted thread. The thread continues until the next breakpoint or request completion.', + toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + thread_id: z.number().int().describe('Thread ID of the halted thread to resume.'), + }, + async execute(args, context) { + const registry = context.serverContext?.debugSessions; + if (!registry) { + throw new Error('Debug session registry not available'); + } + + const entry = registry.getSessionOrThrow(args.session_id); + await entry.manager.resume(args.thread_id); + + return { + thread_id: args.thread_id, + status: 'resumed', + }; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-end-session.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-end-session.ts new file mode 100644 index 000000000..8026320a6 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-end-session.ts @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; + +interface EndSessionInput { + session_id: string; + clear_breakpoints?: boolean; +} + +interface EndSessionOutput { + session_id: string; + status: string; +} + +export function createDebugEndSessionTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_end_session', + description: + 'End a script debugger session. ' + + 'Disconnects from the SDAPI, stops polling, and cleans up resources. ' + + 'Optionally clears all breakpoints before disconnecting.', + toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + clear_breakpoints: z + .boolean() + .optional() + .describe('If true, delete all breakpoints before disconnecting. Defaults to false.'), + }, + async execute(args, context) { + const registry = context.serverContext?.debugSessions; + if (!registry) { + throw new Error('Debug session registry not available'); + } + + const entry = registry.getSessionOrThrow(args.session_id); + + if (args.clear_breakpoints) { + try { + await entry.manager.client.deleteBreakpoints(); + } catch { + // Best-effort + } + } + + await registry.destroySession(args.session_id); + + return { + session_id: args.session_id, + status: 'disconnected', + }; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-evaluate.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-evaluate.ts new file mode 100644 index 000000000..26c2bf029 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-evaluate.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; + +interface EvaluateInput { + session_id: string; + thread_id: number; + frame_index?: number; + expression: string; +} + +interface EvaluateOutput { + expression: string; + result: string; +} + +export function createDebugEvaluateTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_evaluate', + description: + 'Evaluate an expression in the context of a halted thread and stack frame. ' + + 'WARNING: Expressions can have side effects (modify variables, call functions). ' + + 'Use with care on production-like instances.', + toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + thread_id: z.number().int().describe('Thread ID from debug_wait_for_stop.'), + frame_index: z.number().int().min(0).optional().describe('Stack frame index (0 = top frame). Defaults to 0.'), + expression: z.string().describe('JavaScript expression to evaluate in the frame context.'), + }, + async execute(args, context) { + const registry = context.serverContext?.debugSessions; + if (!registry) { + throw new Error('Debug session registry not available'); + } + + const entry = registry.getSessionOrThrow(args.session_id); + const frameIndex = args.frame_index ?? 0; + + const result = await entry.manager.client.evaluate(args.thread_id, frameIndex, args.expression); + + return { + expression: result.expression, + result: result.result, + }; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-stack.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-stack.ts new file mode 100644 index 000000000..7459ee638 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-stack.ts @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; + +interface GetStackInput { + session_id: string; + thread_id: number; +} + +interface GetStackOutput { + thread_id: number; + frames: Array<{ + index: number; + function_name: string; + file: null | string; + line: number; + script_path: string; + }>; +} + +export function createDebugGetStackTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_get_stack', + description: + 'Get the call stack for a halted thread. ' + + 'Returns stack frames with mapped local file paths and server script paths.', + toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + thread_id: z.number().int().describe('Thread ID from debug_wait_for_stop.'), + }, + async execute(args, context) { + const registry = context.serverContext?.debugSessions; + if (!registry) { + throw new Error('Debug session registry not available'); + } + + const entry = registry.getSessionOrThrow(args.session_id); + const thread = await entry.manager.client.getThread(args.thread_id); + + return { + thread_id: thread.id, + frames: thread.call_stack.map((frame) => ({ + index: frame.index, + function_name: frame.location.function_name, + file: entry.sourceMapper.toLocalPath(frame.location.script_path) ?? null, + line: frame.location.line_number, + script_path: frame.location.script_path, + })), + }; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-variables.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-variables.ts new file mode 100644 index 000000000..10749f4df --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-variables.ts @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; + +const MAX_VALUE_LENGTH = 200; +const PRIMITIVE_TYPES = new Set(['boolean', 'Boolean', 'null', 'number', 'Number', 'string', 'String', 'undefined']); + +interface GetVariablesInput { + session_id: string; + thread_id: number; + frame_index?: number; + scope?: string; + object_path?: string; +} + +interface GetVariablesOutput { + variables: Array<{ + name: string; + type: string; + value: string; + scope?: string; + has_children: boolean; + }>; +} + +export function createDebugGetVariablesTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_get_variables', + description: + 'Get variables for a stack frame in a halted thread. ' + + 'By default returns top-frame local variables. ' + + 'Use scope to filter (local, closure, global). ' + + 'Use object_path to drill into nested objects.', + toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + thread_id: z.number().int().describe('Thread ID from debug_wait_for_stop.'), + frame_index: z.number().int().min(0).optional().describe('Stack frame index (0 = top frame). Defaults to 0.'), + scope: z + .enum(['local', 'closure', 'global']) + .optional() + .describe('Filter variables by scope. If omitted, returns all scopes.'), + object_path: z + .string() + .optional() + .describe( + 'Dot-delimited path to drill into an object (e.g. "request.httpParameters"). Returns child members.', + ), + }, + async execute(args, context) { + const registry = context.serverContext?.debugSessions; + if (!registry) { + throw new Error('Debug session registry not available'); + } + + const entry = registry.getSessionOrThrow(args.session_id); + const frameIndex = args.frame_index ?? 0; + + if (args.object_path) { + const result = await entry.manager.client.getMembers(args.thread_id, frameIndex, args.object_path); + return { + variables: result.object_members.map((m) => ({ + name: m.name, + type: m.type, + value: truncateValue(m.value), + has_children: !PRIMITIVE_TYPES.has(m.type), + })), + }; + } + + const result = await entry.manager.client.getVariables(args.thread_id, frameIndex); + let members = result.object_members; + + if (args.scope) { + members = members.filter((m) => m.scope === args.scope); + } + + return { + variables: members.map((m) => ({ + name: m.name, + type: m.type, + value: truncateValue(m.value), + scope: m.scope, + has_children: !PRIMITIVE_TYPES.has(m.type), + })), + }; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} + +function truncateValue(value: string): string { + if (value.length <= MAX_VALUE_LENGTH) return value; + return value.slice(0, MAX_VALUE_LENGTH) + '...'; +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts new file mode 100644 index 000000000..f4e9983e8 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; + +interface ListSessionsOutput { + sessions: Array<{ + session_id: string; + hostname: string; + client_id: string; + halted_threads: number[]; + created_at: string; + last_activity_at: string; + }>; +} + +export function createDebugListSessionsTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter, ListSessionsOutput>( + { + name: 'debug_list_sessions', + description: + 'List all active script debugger sessions. ' + + 'Returns session IDs, connected hostnames, and any currently halted threads. ' + + 'Use this to discover existing sessions before creating a new one.', + toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + inputSchema: {}, + async execute(_args, context) { + const registry = context.serverContext?.debugSessions; + if (!registry) { + throw new Error('Debug session registry not available'); + } + + const entries = registry.listSessions(); + return { + sessions: entries.map((entry) => ({ + session_id: entry.sessionId, + hostname: entry.hostname, + client_id: entry.clientId, + halted_threads: entry.manager + .getKnownThreads() + .filter((t) => t.status === 'halted') + .map((t) => t.id), + created_at: new Date(entry.createdAt).toISOString(), + last_activity_at: new Date(entry.lastActivityAt).toISOString(), + })), + }; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts new file mode 100644 index 000000000..eb508fb0b --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; +import {resolveBreakpointPath, type BreakpointInput} from '@salesforce/b2c-tooling-sdk/operations/debug'; + +interface SetBreakpointsInput { + session_id: string; + breakpoints: Array<{file: string; line: number; condition?: string}>; +} + +interface SetBreakpointsOutput { + breakpoints: Array<{ + id: number; + file: null | string; + line: number; + script_path: string; + verified: boolean; + condition?: string; + }>; +} + +export function createDebugSetBreakpointsTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_set_breakpoints', + description: + 'Set breakpoints in a debug session. Replaces all previously set breakpoints. ' + + 'Accepts local file paths which are mapped to server script paths via cartridge mappings. ' + + 'You can also pass server paths directly (starting with /).', + toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + breakpoints: z + .array( + z.object({ + file: z + .string() + .describe( + 'Local file path or server script path (e.g. /app_storefront/cartridge/controllers/Cart.js).', + ), + line: z.number().int().positive().describe('Line number for the breakpoint.'), + condition: z + .string() + .optional() + .describe('Optional conditional expression. Breakpoint only triggers when this evaluates to true.'), + }), + ) + .describe('Array of breakpoints to set. Replaces all existing breakpoints.'), + }, + async execute(args, context) { + const registry = context.serverContext?.debugSessions; + if (!registry) { + throw new Error('Debug session registry not available'); + } + + const entry = registry.getSessionOrThrow(args.session_id); + + const bpInputs: BreakpointInput[] = args.breakpoints.map((bp) => ({ + script_path: resolveBreakpointPath(bp.file, entry.sourceMapper, entry.cartridges), + line_number: bp.line, + condition: bp.condition, + })); + + const result = await entry.manager.setBreakpoints(bpInputs); + entry.breakpoints = result; + + return { + breakpoints: result.map((bp) => ({ + id: bp.id, + file: entry.sourceMapper.toLocalPath(bp.script_path) ?? null, + line: bp.line_number, + script_path: bp.script_path, + verified: true, + condition: bp.condition, + })), + }; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts new file mode 100644 index 000000000..19fb54b2e --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; +import { + DebugSessionManager, + createSourceMapper, + type DebugSessionCallbacks, + type SdapiScriptThread, +} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {findCartridges} from '@salesforce/b2c-tooling-sdk/operations/code'; + +interface StartSessionInput { + cartridge_directory?: string; + client_id?: string; +} + +interface StartSessionOutput { + session_id: string; + hostname: string; + cartridges: string[]; + warnings: string[]; +} + +export function createDebugStartSessionTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_start_session', + description: + 'Start a new script debugger session on a B2C Commerce instance. ' + + 'Connects to the SDAPI, discovers cartridge mappings, and begins polling for halted threads. ' + + 'WARNING: Debug sessions can halt remote request threads on the instance. ' + + 'Use debug_end_session to cleanly disconnect when done. ' + + 'Requires Basic auth credentials (username/password).', + toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + inputSchema: { + cartridge_directory: z + .string() + .optional() + .describe('Path to directory containing cartridges. Defaults to project directory.'), + client_id: z + .string() + .optional() + .describe( + 'Client ID for the debugger API. Defaults to "b2c-cli". Use a different ID to run concurrent sessions on the same host.', + ), + }, + async execute(args, context) { + const registry = context.serverContext?.debugSessions; + if (!registry) { + throw new Error('Debug session registry not available'); + } + + const credentials = context.services.getBasicAuthCredentials(); + if (!credentials) { + throw new Error( + 'Basic auth credentials (hostname, username, password) are required for the script debugger. ' + + 'Set via SFCC_SERVER/SFCC_USERNAME/SFCC_PASSWORD env vars, or configure in dw.json.', + ); + } + + const {hostname, username, password} = credentials; + + const clientId = args.client_id ?? 'b2c-cli'; + const cartridgeDir = context.services.resolveWithProjectDirectory(args.cartridge_directory); + const cartridges = findCartridges(cartridgeDir); + const warnings: string[] = []; + + if (cartridges.length === 0) { + warnings.push(`No cartridges found in ${cartridgeDir}. Breakpoints will use server paths only.`); + } + + const sourceMapper = createSourceMapper(cartridges); + + const callbacks: DebugSessionCallbacks = { + onThreadStopped(thread: SdapiScriptThread) { + const entry = registry.findByHostAndClientId(hostname, clientId); + if (!entry) return; + + // Resolve any pending halt waiters + while (entry.haltWaiters.length > 0) { + const waiter = entry.haltWaiters.shift()!; + clearTimeout(waiter.timer); + waiter.resolve(thread); + } + }, + }; + + const manager = new DebugSessionManager( + {hostname, username, password, clientId, cartridgeRoots: cartridges}, + callbacks, + ); + + await manager.connect(); + + const entry = registry.registerSession(hostname, clientId, manager, sourceMapper, cartridges); + + return { + session_id: entry.sessionId, + hostname, + cartridges: cartridges.map((c) => c.name), + warnings, + }; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-step.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-step.ts new file mode 100644 index 000000000..cedef805d --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-step.ts @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; + +interface StepInput { + session_id: string; + thread_id: number; +} + +interface StepOutput { + thread_id: number; + action: string; +} + +type StepAction = 'step_into' | 'step_out' | 'step_over'; + +function createStepTool( + action: StepAction, + description: string, + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: `debug_${action}`, + description: description + ' Follow with debug_wait_for_stop to see where execution landed.', + toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + thread_id: z.number().int().describe('Thread ID of the halted thread to step.'), + }, + async execute(args, context) { + const registry = context.serverContext?.debugSessions; + if (!registry) { + throw new Error('Debug session registry not available'); + } + + const entry = registry.getSessionOrThrow(args.session_id); + const manager = entry.manager; + + switch (action) { + case 'step_into': { + await manager.stepInto(args.thread_id); + break; + } + case 'step_out': { + await manager.stepOut(args.thread_id); + break; + } + case 'step_over': { + await manager.stepOver(args.thread_id); + break; + } + } + + return { + thread_id: args.thread_id, + action, + }; + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} + +export function createDebugStepTools( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool[] { + return [ + createStepTool('step_over', 'Step to the next line in the current function.', loadServices, serverContext), + createStepTool('step_into', 'Step into the function call on the current line.', loadServices, serverContext), + createStepTool( + 'step_out', + 'Step out of the current function, returning to the caller.', + loadServices, + serverContext, + ), + ]; +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts new file mode 100644 index 000000000..acc481ab6 --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {z} from 'zod'; +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createToolAdapter, jsonResult} from '../adapter.js'; +import type {SdapiScriptThread} from '@salesforce/b2c-tooling-sdk/operations/debug'; + +const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_TIMEOUT_MS = 120_000; + +interface WaitForStopInput { + session_id: string; + timeout_ms?: number; +} + +interface WaitForStopOutput { + halted: boolean; + timed_out?: boolean; + thread_id?: number; + location?: { + file: null | string; + line: number; + function_name: string; + script_path: string; + }; +} + +export function createDebugWaitForStopTool( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool { + return createToolAdapter( + { + name: 'debug_wait_for_stop', + description: + 'Wait for a thread to halt at a breakpoint or step. ' + + 'Returns immediately if a thread is already halted. ' + + 'Otherwise blocks until a halt occurs or the timeout expires.', + toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + inputSchema: { + session_id: z.string().describe('Session ID returned by debug_start_session.'), + timeout_ms: z + .number() + .int() + .positive() + .max(MAX_TIMEOUT_MS) + .optional() + .describe(`Timeout in milliseconds (default: ${DEFAULT_TIMEOUT_MS}, max: ${MAX_TIMEOUT_MS}).`), + }, + async execute(args, context) { + const registry = context.serverContext?.debugSessions; + if (!registry) { + throw new Error('Debug session registry not available'); + } + + const entry = registry.getSessionOrThrow(args.session_id); + const timeout = Math.min(args.timeout_ms ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS); + + // Check if any thread is already halted + const haltedThread = entry.manager.getKnownThreads().find((t) => t.status === 'halted'); + if (haltedThread) { + return formatHaltResult(haltedThread, entry.sourceMapper); + } + + // Wait for a halt via the callback mechanism + const thread = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = entry.haltWaiters.findIndex((w) => w.timer === timer); + if (idx !== -1) entry.haltWaiters.splice(idx, 1); + resolve(null); + }, timeout); + + entry.haltWaiters.push({resolve: (t) => resolve(t), reject, timer}); + }); + + if (!thread) { + return {halted: false, timed_out: true}; + } + + return formatHaltResult(thread, entry.sourceMapper); + }, + formatOutput: (output) => jsonResult(output), + }, + loadServices, + serverContext, + ); +} + +function formatHaltResult( + thread: SdapiScriptThread, + sourceMapper: {toLocalPath: (path: string) => string | undefined}, +): WaitForStopOutput { + const topFrame = thread.call_stack?.[0]; + const loc = topFrame?.location; + return { + halted: true, + thread_id: thread.id, + location: loc + ? { + file: sourceMapper.toLocalPath(loc.script_path) ?? null, + line: loc.line_number, + function_name: loc.function_name, + script_path: loc.script_path, + } + : undefined, + }; +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/index.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/index.ts new file mode 100644 index 000000000..99a1834df --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/index.ts @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import type {McpTool} from '../../utils/index.js'; +import type {Services} from '../../services.js'; +import type {ServerContext} from '../../server-context.js'; +import {createDebugListSessionsTool} from './debug-list-sessions.js'; +import {createDebugStartSessionTool} from './debug-start-session.js'; +import {createDebugEndSessionTool} from './debug-end-session.js'; +import {createDebugSetBreakpointsTool} from './debug-set-breakpoints.js'; +import {createDebugWaitForStopTool} from './debug-wait-for-stop.js'; +import {createDebugGetStackTool} from './debug-get-stack.js'; +import {createDebugGetVariablesTool} from './debug-get-variables.js'; +import {createDebugEvaluateTool} from './debug-evaluate.js'; +import {createDebugContinueTool} from './debug-continue.js'; +import {createDebugStepTools} from './debug-step.js'; +import {createDebugCaptureAtBreakpointTool} from './debug-capture-at-breakpoint.js'; + +export function createDiagnosticsTools( + loadServices: () => Promise | Services, + serverContext?: ServerContext, +): McpTool[] { + return [ + createDebugListSessionsTool(loadServices, serverContext), + createDebugStartSessionTool(loadServices, serverContext), + createDebugEndSessionTool(loadServices, serverContext), + createDebugSetBreakpointsTool(loadServices, serverContext), + createDebugWaitForStopTool(loadServices, serverContext), + createDebugGetStackTool(loadServices, serverContext), + createDebugGetVariablesTool(loadServices, serverContext), + createDebugEvaluateTool(loadServices, serverContext), + createDebugContinueTool(loadServices, serverContext), + ...createDebugStepTools(loadServices, serverContext), + createDebugCaptureAtBreakpointTool(loadServices, serverContext), + ]; +} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/session-registry.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/session-registry.ts new file mode 100644 index 000000000..c7954bffe --- /dev/null +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/session-registry.ts @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {randomUUID} from 'node:crypto'; +import {getLogger} from '@salesforce/b2c-tooling-sdk/logging'; +import type { + DebugSessionManager, + SourceMapper, + SdapiBreakpoint, + SdapiScriptThread, +} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import type {CartridgeMapping} from '@salesforce/b2c-tooling-sdk/operations/code'; + +const IDLE_TTL_MS = 30 * 60 * 1000; // 30 minutes +const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + +export interface HaltWaiter { + resolve: (thread: SdapiScriptThread) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +export interface DebugSessionEntry { + sessionId: string; + hostname: string; + clientId: string; + manager: DebugSessionManager; + sourceMapper: SourceMapper; + cartridges: CartridgeMapping[]; + breakpoints: SdapiBreakpoint[]; + haltWaiters: HaltWaiter[]; + createdAt: number; + lastActivityAt: number; +} + +export class DebugSessionRegistry { + private cleanupTimer: ReturnType | undefined; + private readonly sessions = new Map(); + + constructor() { + this.cleanupTimer = setInterval(() => { + this.cleanupIdleSessions().catch(() => {}); + }, CLEANUP_INTERVAL_MS); + this.cleanupTimer.unref(); + } + + async destroyAll(): Promise { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = undefined; + } + + const destroyPromises = [...this.sessions.keys()].map((id) => this.destroySession(id)); + await Promise.allSettled(destroyPromises); + } + + async destroySession(sessionId: string): Promise { + const entry = this.sessions.get(sessionId); + if (!entry) return; + + for (const waiter of entry.haltWaiters) { + clearTimeout(waiter.timer); + waiter.reject(new Error('Debug session ended')); + } + entry.haltWaiters.length = 0; + + try { + await entry.manager.disconnect(); + } catch { + // Best-effort disconnect + } + + this.sessions.delete(sessionId); + } + + findByHostAndClientId(hostname: string, clientId: string): DebugSessionEntry | undefined { + for (const entry of this.sessions.values()) { + if (entry.hostname === hostname && entry.clientId === clientId) { + return entry; + } + } + return undefined; + } + + getSession(sessionId: string): DebugSessionEntry | undefined { + return this.sessions.get(sessionId); + } + + getSessionOrThrow(sessionId: string): DebugSessionEntry { + const entry = this.sessions.get(sessionId); + if (!entry) { + throw new Error(`No debug session found with id "${sessionId}". Use debug_list_sessions to see active sessions.`); + } + entry.lastActivityAt = Date.now(); + return entry; + } + + listSessions(): DebugSessionEntry[] { + return [...this.sessions.values()]; + } + + registerSession( + hostname: string, + clientId: string, + manager: DebugSessionManager, + sourceMapper: SourceMapper, + cartridges: CartridgeMapping[], + ): DebugSessionEntry { + const existing = this.findByHostAndClientId(hostname, clientId); + if (existing) { + throw new Error( + `A debug session already exists for ${hostname} with client ID "${clientId}" ` + + `(session_id: "${existing.sessionId}"). ` + + `End it with debug_end_session first, or use a different client_id.`, + ); + } + + const sessionId = randomUUID(); + const now = Date.now(); + const entry: DebugSessionEntry = { + sessionId, + hostname, + clientId, + manager, + sourceMapper, + cartridges, + breakpoints: [], + haltWaiters: [], + createdAt: now, + lastActivityAt: now, + }; + this.sessions.set(sessionId, entry); + return entry; + } + + private async cleanupIdleSessions(): Promise { + const logger = getLogger(); + const now = Date.now(); + + const idleSessions = [...this.sessions.entries()].filter(([, entry]) => now - entry.lastActivityAt > IDLE_TTL_MS); + + await Promise.allSettled( + idleSessions.map(([sessionId, entry]) => { + logger.info({sessionId, hostname: entry.hostname}, 'Cleaning up idle debug session'); + return this.destroySession(sessionId); + }), + ); + } +} diff --git a/packages/b2c-dx-mcp/src/tools/index.ts b/packages/b2c-dx-mcp/src/tools/index.ts index cdb142303..1d884d510 100644 --- a/packages/b2c-dx-mcp/src/tools/index.ts +++ b/packages/b2c-dx-mcp/src/tools/index.ts @@ -19,6 +19,7 @@ export * from './adapter.js'; // Toolset exports export * from './cartridges/index.js'; +export * from './diagnostics/index.js'; export * from './mrt/index.js'; export * from './pwav3/index.js'; export * from './scapi/index.js'; diff --git a/packages/b2c-tooling-sdk/src/operations/debug/index.ts b/packages/b2c-tooling-sdk/src/operations/debug/index.ts index 9d4803b1b..604e359f7 100644 --- a/packages/b2c-tooling-sdk/src/operations/debug/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/debug/index.ts @@ -18,6 +18,7 @@ export {B2CScriptDebugAdapter} from './dap-adapter.js'; export {createSourceMapper} from './source-mapping.js'; export type {SourceMapper} from './source-mapping.js'; export {VariableStore} from './variable-store.js'; +export {resolveBreakpointPath} from './resolve-path.js'; export type {VariableRef} from './variable-store.js'; export type { SdapiLocation, diff --git a/packages/b2c-tooling-sdk/src/operations/debug/resolve-path.ts b/packages/b2c-tooling-sdk/src/operations/debug/resolve-path.ts new file mode 100644 index 000000000..c04e982ea --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/debug/resolve-path.ts @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import path from 'node:path'; +import type {SourceMapper} from './source-mapping.js'; +import type {CartridgeMapping} from '../code/cartridges.js'; + +/** + * Resolve a user-provided file path to an SDAPI script_path. + * + * Accepts: + * - Server path: `/app_storefront/cartridge/controllers/Cart.js` (passed through) + * - Absolute local path: `/Users/.../app_storefront/cartridge/controllers/Cart.js` + * - Relative local path: `./cartridges/app_storefront/cartridge/controllers/Cart.js` + * - Cartridge-name-prefixed: `app_storefront/cartridge/controllers/Cart.js` (prefixed with /) + * + * Returns the SDAPI script_path (e.g. `/app_storefront/cartridge/controllers/Cart.js`). + * Throws with a helpful message if the path cannot be resolved. + */ +export function resolveBreakpointPath( + input: string, + sourceMapper: SourceMapper, + cartridges: CartridgeMapping[], +): string { + if (input.startsWith('/')) { + return input; + } + + // Try direct source mapper (handles absolute and resolvable relative paths) + const mapped = sourceMapper.toServerPath(input); + if (mapped) return mapped; + + // Try resolving relative to cwd + const resolved = path.resolve(input); + const mappedResolved = sourceMapper.toServerPath(resolved); + if (mappedResolved) return mappedResolved; + + // Check if the input starts with a known cartridge name — treat as server path missing leading / + const normalized = input.split(path.sep).join('/'); + const firstSegment = normalized.split('/')[0]; + if (cartridges.some((c) => c.name === firstSegment)) { + return `/${normalized}`; + } + + const cartridgeNames = cartridges.map((c) => c.name).join(', '); + const hint = cartridges.length > 0 + ? `Known cartridges: ${cartridgeNames}\n` + + `Accepted forms:\n` + + ` /cartridge_name/cartridge/path/to/file.js (server path)\n` + + ` cartridge_name/cartridge/path/to/file.js (server path without leading /)\n` + + ` ./path/to/cartridge_name/cartridge/file.js (relative local path)\n` + + ` /absolute/path/to/cartridge_name/file.js (absolute local path)` + : 'No cartridges discovered. Use a server path: /cartridge_name/cartridge/path/file.js'; + + throw new Error(`Cannot resolve "${input}" to a server script path.\n${hint}`); +} diff --git a/skills/b2c-cli/skills/b2c-debug/SKILL.md b/skills/b2c-cli/skills/b2c-debug/SKILL.md new file mode 100644 index 000000000..4dd7cbe13 --- /dev/null +++ b/skills/b2c-cli/skills/b2c-debug/SKILL.md @@ -0,0 +1,127 @@ +--- +name: b2c-debug +description: Debug B2C Commerce server-side scripts using the b2c CLI. Use this skill whenever the user needs to set breakpoints, step through code, inspect variables, evaluate expressions, or investigate runtime behavior on a B2C Commerce instance. Also use when the user wants to understand what a script is doing at runtime, capture state at a specific line, or drive the debugger from a headless script — even if they just say "debug this controller" or "what's the value of basket at line 42". +--- + +# B2C Debug Skill + +Use the `b2c` CLI to debug server-side scripts on Salesforce B2C Commerce instances. The `debug cli` command provides an interactive REPL for terminal debugging, with an `--rpc` mode for headless/programmatic use. + +> **Tip:** If `b2c` is not installed globally, use `npx @salesforce/b2c-cli` instead (e.g., `npx @salesforce/b2c-cli debug cli`). + +## Prerequisites + +- Basic Auth credentials (username/password) for a BM user with `WebDAV_Manage_Customization` +- Script Debugger enabled: BM > Administration > Development Configuration > Enable Script Debugger + +## Interactive Debugging + +### Start a Debug Session + +```bash +# Start interactive debugger +b2c debug cli + +# Specify cartridge directory for source mapping +b2c debug cli --cartridge-path ./cartridges + +# Use a custom client ID (for concurrent sessions) +b2c debug cli --client-id my-session +``` + +### Set Breakpoints + +In the REPL: + +``` +break Cart.js:42 +break Checkout.js:100 if basket.totalGrossPrice > 100 +breakpoints +delete 1 +``` + +### Inspect State When Halted + +``` +stack +vars +members basket.productLineItems +eval basket.productLineItems.length +eval request.httpParameterMap.get("pid").stringValue +``` + +### Control Execution + +``` +continue +step +stepin +stepout +``` + +### Thread Management + +``` +threads +thread 5 +frame 2 +``` + +## RPC Mode (Headless / Agent Use) + +For headless scripts, agents, and programmatic integration, use `--rpc` mode. Commands and responses are JSONL (one JSON object per line) on stdin/stdout. + +```bash +b2c debug cli --rpc +``` + +### Send Commands + +```json +{"id": 1, "command": "set_breakpoints", "args": {"breakpoints": [{"file": "Cart.js", "line": 42}]}} +{"id": 2, "command": "get_stack"} +{"id": 3, "command": "get_variables", "args": {"scope": "local"}} +{"id": 4, "command": "evaluate", "args": {"expression": "basket.totalGrossPrice"}} +{"id": 5, "command": "continue"} +``` + +### Receive Responses and Events + +```json +{"event": "ready", "data": {}} +{"id": 1, "result": {"breakpoints": [{"id": 1, "file": "Cart.js", "line": 42, "script_path": "/app_storefront/cartridge/controllers/Cart.js"}]}} +{"event": "thread_stopped", "data": {"thread_id": 5, "location": {"file": "Cart.js", "line": 42, "function_name": "show"}}} +``` + +### Available RPC Commands + +| Command | Key Args | Description | +|---------|----------|-------------| +| `set_breakpoints` | `breakpoints: [{file, line, condition?}]` | Replace all breakpoints | +| `list_breakpoints` | | List current breakpoints | +| `continue` | `thread_id?` | Resume halted thread | +| `step_over` | `thread_id?` | Step to next line | +| `step_into` | `thread_id?` | Step into function | +| `step_out` | `thread_id?` | Step out of function | +| `get_stack` | `thread_id?` | Get call stack | +| `get_variables` | `thread_id?, frame_index?, scope?, object_path?` | Get variables | +| `evaluate` | `expression, thread_id?, frame_index?` | Evaluate expression | +| `list_threads` | | List threads | +| `select_thread` | `thread_id` | Switch thread | +| `select_frame` | `index` | Switch frame | + +## DAP Mode (IDE Integration) + +For VS Code and other DAP-compatible IDEs: + +```bash +b2c debug +``` + +This starts a DAP adapter over stdio, used by IDE launch configurations. + +## Related Skills + +- `b2c-cli:b2c-logs` - Retrieve server logs for investigating errors found during debugging +- `b2c-cli:b2c-code` - Deploy code changes before debugging +- `b2c-cli:b2c-config` - Verify instance configuration and credentials diff --git a/skills/b2c-cli/skills/b2c-debug/evals/trigger-evals.json b/skills/b2c-cli/skills/b2c-debug/evals/trigger-evals.json new file mode 100644 index 000000000..ea90d34f6 --- /dev/null +++ b/skills/b2c-cli/skills/b2c-debug/evals/trigger-evals.json @@ -0,0 +1,16 @@ +[ + {"query": "debug this controller", "should_trigger": true}, + {"query": "set a breakpoint on line 42 of Cart.js", "should_trigger": true}, + {"query": "what's the value of basket at line 100", "should_trigger": true}, + {"query": "step through the checkout code", "should_trigger": true}, + {"query": "start a debug session on my sandbox", "should_trigger": true}, + {"query": "inspect variables when the order is placed", "should_trigger": true}, + {"query": "I need to debug why the payment hook fails", "should_trigger": true}, + {"query": "how do I use the debugger from a script", "should_trigger": true}, + {"query": "connect to the script debugger via RPC", "should_trigger": true}, + {"query": "evaluate an expression in the halted thread", "should_trigger": true}, + {"query": "deploy my cartridges to the sandbox", "should_trigger": false}, + {"query": "check the error logs for failures", "should_trigger": false}, + {"query": "create a new custom object type", "should_trigger": false}, + {"query": "search for products using SCAPI", "should_trigger": false} +] From fc80ac58eacb72547096cbb97dbd72d814f1b043 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Sun, 3 May 2026 20:37:18 -0400 Subject: [PATCH 02/14] Add MCP diagnostics tools doc page and sidebar sub-group Group debug tools on a single Diagnostics page instead of individual pages. Add Diagnostics sub-heading under MCP Tools in the sidebar for future tools like log tailing. --- docs/.vitepress/config.mts | 5 + docs/mcp/tools/diagnostics.md | 182 ++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 docs/mcp/tools/diagnostics.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index e6e21efee..56d84e159 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -140,6 +140,11 @@ const referenceSidebar = [ {text: 'sfnext_match_tokens_to_theme', link: '/mcp/tools/sfnext-match-tokens-to-theme'}, {text: 'sfnext_add_page_designer_decorator', link: '/mcp/tools/sfnext-add-page-designer-decorator'}, {text: 'sfnext_configure_theme', link: '/mcp/tools/sfnext-configure-theme'}, + { + text: 'Diagnostics', + collapsed: false, + items: [{text: 'Script Debugger', link: '/mcp/tools/diagnostics'}], + }, ], }, ]; diff --git a/docs/mcp/tools/diagnostics.md b/docs/mcp/tools/diagnostics.md new file mode 100644 index 000000000..9beff7d30 --- /dev/null +++ b/docs/mcp/tools/diagnostics.md @@ -0,0 +1,182 @@ +--- +description: MCP tools for script debugging on B2C Commerce instances. +--- + +# Diagnostics Tools + +MCP tools for connecting to the B2C Commerce Script Debugger API (SDAPI), setting breakpoints, inspecting variables, and stepping through server-side code. These tools are available in the **CARTRIDGES**, **SCAPI**, and **STOREFRONTNEXT** toolsets. + +## Authentication + +All debug tools require **Basic Auth** credentials (username and password) for a Business Manager user with the `WebDAV_Manage_Customization` permission. + +The script debugger must be enabled on the instance: Business Manager > Administration > Development Configuration > Script Debugger > Enable. + +--- + +## Session Lifecycle + +### debug_start_session + +Start a new script debugger session. Connects to the SDAPI, discovers cartridge mappings, and begins polling for halted threads. + +> **Warning:** Debug sessions can halt remote request threads on the instance. Use `debug_end_session` to cleanly disconnect when done. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `cartridge_directory` | string | No | Project directory | Path to directory containing cartridges | +| `client_id` | string | No | `b2c-cli` | Client ID for the debugger API. Use a different ID for concurrent sessions on the same host. | + +**Returns:** `session_id`, `hostname`, discovered `cartridges`, and `warnings`. + +### debug_end_session + +End a script debugger session. Disconnects from the SDAPI, stops polling, and cleans up resources. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `session_id` | string | Yes | | Session ID from `debug_start_session` | +| `clear_breakpoints` | boolean | No | `false` | Delete all breakpoints before disconnecting | + +### debug_list_sessions + +List all active debug sessions. Returns session IDs, connected hostnames, and any currently halted threads. + +No parameters. + +--- + +## Breakpoints + +### debug_set_breakpoints + +Set breakpoints in a debug session. **Replaces** all previously set breakpoints. + +Accepts local file paths (mapped to server paths via cartridge discovery), cartridge-prefixed paths (e.g. `app_storefront/cartridge/controllers/Cart.js`), or server paths starting with `/`. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `session_id` | string | Yes | Session ID from `debug_start_session` | +| `breakpoints` | array | Yes | Array of `{file, line, condition?}` objects | + +Each breakpoint object: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `file` | string | Yes | Local file path, cartridge-prefixed path, or server script path | +| `line` | number | Yes | Line number | +| `condition` | string | No | Conditional expression — breakpoint only triggers when true | + +--- + +## Execution Control + +### debug_wait_for_stop + +Wait for a thread to halt at a breakpoint or step. Returns immediately if a thread is already halted. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `session_id` | string | Yes | | Session ID | +| `timeout_ms` | number | No | 30000 | Timeout in milliseconds (max 120000) | + +**Returns:** `{halted, thread_id, location}` or `{halted: false, timed_out: true}`. + +### debug_continue + +Resume execution of a halted thread. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `session_id` | string | Yes | Session ID | +| `thread_id` | number | Yes | Thread ID of the halted thread | + +### debug_step_over + +Step to the next line in the current function. Follow with `debug_wait_for_stop`. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `session_id` | string | Yes | Session ID | +| `thread_id` | number | Yes | Thread ID | + +### debug_step_into + +Step into the function call on the current line. Follow with `debug_wait_for_stop`. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `session_id` | string | Yes | Session ID | +| `thread_id` | number | Yes | Thread ID | + +### debug_step_out + +Step out of the current function, returning to the caller. Follow with `debug_wait_for_stop`. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `session_id` | string | Yes | Session ID | +| `thread_id` | number | Yes | Thread ID | + +--- + +## Inspection + +### debug_get_stack + +Get the call stack for a halted thread. Returns stack frames with mapped local file paths and server script paths. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `session_id` | string | Yes | Session ID | +| `thread_id` | number | Yes | Thread ID | + +### debug_get_variables + +Get variables for a stack frame in a halted thread. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `session_id` | string | Yes | | Session ID | +| `thread_id` | number | Yes | | Thread ID | +| `frame_index` | number | No | `0` | Stack frame index (0 = top frame) | +| `scope` | string | No | All scopes | Filter by `local`, `closure`, or `global` | +| `object_path` | string | No | | Dot-delimited path to drill into an object (e.g. `request.httpParameters`) | + +### debug_evaluate + +Evaluate an expression in the context of a halted thread and stack frame. + +> **Warning:** Expressions can have side effects (modify variables, call functions). Use with care. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `session_id` | string | Yes | | Session ID | +| `thread_id` | number | Yes | | Thread ID | +| `frame_index` | number | No | `0` | Stack frame index | +| `expression` | string | Yes | | JavaScript expression to evaluate | + +--- + +## Higher-Level Tools + +### debug_capture_at_breakpoint + +Set a breakpoint, wait for it to be hit, and capture a diagnostic snapshot — stack, variables, and optional expression results in a single call. Optionally resumes the thread after capture. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `session_id` | string | Yes | | Session ID | +| `file` | string | Yes | | File path for the breakpoint | +| `line` | number | Yes | | Line number | +| `condition` | string | No | | Conditional expression | +| `expressions` | string[] | No | | Expressions to evaluate when hit | +| `timeout_ms` | number | No | 30000 | Timeout waiting for the breakpoint (max 120000) | +| `auto_continue` | boolean | No | `false` | Resume the thread after capturing | + +--- + +## See Also + +- [Debug CLI Commands](/cli/debug) — Interactive REPL and RPC mode for the CLI +- [Configuration](../configuration) — Setting up instance credentials From 09c18c533e6ac37e49c8821a02e5c5e81b022027 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Sun, 3 May 2026 20:44:14 -0400 Subject: [PATCH 03/14] Group MCP tools sidebar by use case Organize into Cartridges, SCAPI, PWA Kit, Storefront Next, and Diagnostics sub-groups. --- docs/.vitepress/config.mts | 46 ++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 56d84e159..4dd4eaf73 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -128,18 +128,40 @@ const referenceSidebar = [ { text: 'MCP Tools', items: [ - {text: 'cartridge_deploy', link: '/mcp/tools/cartridge-deploy'}, - {text: 'mrt_bundle_push', link: '/mcp/tools/mrt-bundle-push'}, - {text: 'pwakit_get_guidelines', link: '/mcp/tools/pwakit-get-guidelines'}, - {text: 'scapi_schemas_list', link: '/mcp/tools/scapi-schemas-list'}, - {text: 'scapi_custom_api_generate_scaffold', link: '/mcp/tools/scapi-custom-api-generate-scaffold'}, - {text: 'scapi_custom_apis_get_status', link: '/mcp/tools/scapi-custom-apis-get-status'}, - {text: 'sfnext_get_guidelines', link: '/mcp/tools/sfnext-get-guidelines'}, - {text: 'sfnext_start_figma_workflow', link: '/mcp/tools/sfnext-start-figma-workflow'}, - {text: 'sfnext_analyze_component', link: '/mcp/tools/sfnext-analyze-component'}, - {text: 'sfnext_match_tokens_to_theme', link: '/mcp/tools/sfnext-match-tokens-to-theme'}, - {text: 'sfnext_add_page_designer_decorator', link: '/mcp/tools/sfnext-add-page-designer-decorator'}, - {text: 'sfnext_configure_theme', link: '/mcp/tools/sfnext-configure-theme'}, + { + text: 'Cartridges', + collapsed: false, + items: [{text: 'cartridge_deploy', link: '/mcp/tools/cartridge-deploy'}], + }, + { + text: 'SCAPI', + collapsed: false, + items: [ + {text: 'scapi_schemas_list', link: '/mcp/tools/scapi-schemas-list'}, + {text: 'scapi_custom_api_generate_scaffold', link: '/mcp/tools/scapi-custom-api-generate-scaffold'}, + {text: 'scapi_custom_apis_get_status', link: '/mcp/tools/scapi-custom-apis-get-status'}, + ], + }, + { + text: 'PWA Kit', + collapsed: false, + items: [ + {text: 'mrt_bundle_push', link: '/mcp/tools/mrt-bundle-push'}, + {text: 'pwakit_get_guidelines', link: '/mcp/tools/pwakit-get-guidelines'}, + ], + }, + { + text: 'Storefront Next', + collapsed: false, + items: [ + {text: 'sfnext_get_guidelines', link: '/mcp/tools/sfnext-get-guidelines'}, + {text: 'sfnext_start_figma_workflow', link: '/mcp/tools/sfnext-start-figma-workflow'}, + {text: 'sfnext_analyze_component', link: '/mcp/tools/sfnext-analyze-component'}, + {text: 'sfnext_match_tokens_to_theme', link: '/mcp/tools/sfnext-match-tokens-to-theme'}, + {text: 'sfnext_add_page_designer_decorator', link: '/mcp/tools/sfnext-add-page-designer-decorator'}, + {text: 'sfnext_configure_theme', link: '/mcp/tools/sfnext-configure-theme'}, + ], + }, { text: 'Diagnostics', collapsed: false, From 9247381f35dbabcc770d82a617e48cee6008acfa Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Sun, 3 May 2026 20:44:55 -0400 Subject: [PATCH 04/14] Collapse MCP tools sidebar groups by default --- docs/.vitepress/config.mts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 4dd4eaf73..e4b1a1a93 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -130,12 +130,12 @@ const referenceSidebar = [ items: [ { text: 'Cartridges', - collapsed: false, + collapsed: true, items: [{text: 'cartridge_deploy', link: '/mcp/tools/cartridge-deploy'}], }, { text: 'SCAPI', - collapsed: false, + collapsed: true, items: [ {text: 'scapi_schemas_list', link: '/mcp/tools/scapi-schemas-list'}, {text: 'scapi_custom_api_generate_scaffold', link: '/mcp/tools/scapi-custom-api-generate-scaffold'}, @@ -144,7 +144,7 @@ const referenceSidebar = [ }, { text: 'PWA Kit', - collapsed: false, + collapsed: true, items: [ {text: 'mrt_bundle_push', link: '/mcp/tools/mrt-bundle-push'}, {text: 'pwakit_get_guidelines', link: '/mcp/tools/pwakit-get-guidelines'}, @@ -152,7 +152,7 @@ const referenceSidebar = [ }, { text: 'Storefront Next', - collapsed: false, + collapsed: true, items: [ {text: 'sfnext_get_guidelines', link: '/mcp/tools/sfnext-get-guidelines'}, {text: 'sfnext_start_figma_workflow', link: '/mcp/tools/sfnext-start-figma-workflow'}, @@ -164,7 +164,7 @@ const referenceSidebar = [ }, { text: 'Diagnostics', - collapsed: false, + collapsed: true, items: [{text: 'Script Debugger', link: '/mcp/tools/diagnostics'}], }, ], From d10d760d57e7f63f079de1486adebefee2d80883 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Sun, 3 May 2026 21:18:25 -0400 Subject: [PATCH 05/14] Clarify that debug_wait_for_stop and debug_capture_at_breakpoint block Make it explicit in tool descriptions and docs that these tools block until a breakpoint is hit or timeout expires, and that the caller must trigger a request externally while waiting. --- docs/mcp/tools/diagnostics.md | 4 +++- .../src/tools/diagnostics/debug-capture-at-breakpoint.ts | 3 ++- .../b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/mcp/tools/diagnostics.md b/docs/mcp/tools/diagnostics.md index 9beff7d30..e6c126dc7 100644 --- a/docs/mcp/tools/diagnostics.md +++ b/docs/mcp/tools/diagnostics.md @@ -73,7 +73,7 @@ Each breakpoint object: ### debug_wait_for_stop -Wait for a thread to halt at a breakpoint or step. Returns immediately if a thread is already halted. +Wait for a thread to halt at a breakpoint or step. Returns immediately if a thread is already halted. Otherwise **blocks** until a halt occurs or the timeout expires — the user or an external process must trigger a request on the instance while this tool is waiting. | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| @@ -164,6 +164,8 @@ Evaluate an expression in the context of a halted thread and stack frame. Set a breakpoint, wait for it to be hit, and capture a diagnostic snapshot — stack, variables, and optional expression results in a single call. Optionally resumes the thread after capture. +> **Important:** This tool **blocks** until the breakpoint is hit or the timeout expires. The user or an external process must trigger a request on the instance while this tool is waiting. + | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `session_id` | string | Yes | | Session ID | diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts index 774959796..3206b4940 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts @@ -69,8 +69,9 @@ export function createDebugCaptureAtBreakpointTool( name: 'debug_capture_at_breakpoint', description: 'Set a breakpoint, wait for it to be hit, and capture a diagnostic snapshot (stack, variables, expression results). ' + + 'IMPORTANT: This tool BLOCKS until the breakpoint is hit or the timeout expires — the user or an external process must trigger a request on the instance while this tool is waiting. ' + 'Optionally resumes the thread after capture. ' + - 'This is a higher-level tool that combines debug_set_breakpoints, debug_wait_for_stop, debug_get_stack, debug_get_variables, and debug_evaluate.', + 'Combines debug_set_breakpoints, debug_wait_for_stop, debug_get_stack, debug_get_variables, and debug_evaluate in a single call.', toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts index acc481ab6..35a4bbd36 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts @@ -41,7 +41,7 @@ export function createDebugWaitForStopTool( description: 'Wait for a thread to halt at a breakpoint or step. ' + 'Returns immediately if a thread is already halted. ' + - 'Otherwise blocks until a halt occurs or the timeout expires.', + 'Otherwise BLOCKS until a halt occurs or the timeout expires — the user or an external process must trigger a request on the instance while this tool is waiting.', toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), From 5336e328225a3183953cb7d6b1d9301d12d8afbc Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Sun, 3 May 2026 21:36:11 -0400 Subject: [PATCH 06/14] Improve MCP debug tools based on real-world agent feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. capture_at_breakpoint: add trigger_url parameter so the tool can fire the HTTP request itself after arming the breakpoint, avoiding the blocking coordination problem with LLM tool-calling. 2. set_breakpoints: surface warnings when a path cannot be round-trip mapped back to a local file. Set verified=false for unmapped paths instead of silently accepting them. 3. Tool descriptions: steer toward the non-blocking workflow (set_breakpoints → trigger → list_sessions → inspect) rather than leading with blocking tools. 4. list_sessions: include active breakpoints with resolved server paths so agents can inspect what's armed on the server. 5. start_session: return cartridge_mappings (name → local path) so agents can verify cartridge discovery and path mapping. --- .../debug-capture-at-breakpoint.ts | 33 +++++++++---- .../tools/diagnostics/debug-list-sessions.ts | 18 +++++-- .../diagnostics/debug-set-breakpoints.ts | 47 ++++++++++++------- .../tools/diagnostics/debug-start-session.ts | 7 +++ .../tools/diagnostics/debug-wait-for-stop.ts | 3 +- 5 files changed, 79 insertions(+), 29 deletions(-) diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts index 3206b4940..da9f06357 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts @@ -28,6 +28,7 @@ interface CaptureInput { expressions?: string[]; timeout_ms?: number; auto_continue?: boolean; + trigger_url?: string; } interface CaptureOutput { @@ -58,6 +59,7 @@ interface CaptureOutput { result: string; }>; auto_continued: boolean; + trigger_status?: number; } export function createDebugCaptureAtBreakpointTool( @@ -68,10 +70,10 @@ export function createDebugCaptureAtBreakpointTool( { name: 'debug_capture_at_breakpoint', description: - 'Set a breakpoint, wait for it to be hit, and capture a diagnostic snapshot (stack, variables, expression results). ' + - 'IMPORTANT: This tool BLOCKS until the breakpoint is hit or the timeout expires — the user or an external process must trigger a request on the instance while this tool is waiting. ' + - 'Optionally resumes the thread after capture. ' + - 'Combines debug_set_breakpoints, debug_wait_for_stop, debug_get_stack, debug_get_variables, and debug_evaluate in a single call.', + 'Set a breakpoint, optionally trigger an HTTP request, wait for the breakpoint to be hit, and capture a diagnostic snapshot (stack, variables, expression results). ' + + 'Use trigger_url to have the tool fire the request itself (recommended) — this avoids needing to coordinate a separate request while the tool blocks. ' + + 'Without trigger_url, the tool BLOCKS until the breakpoint is hit or timeout expires and requires the user to trigger a request externally. ' + + 'For more control, use the non-blocking workflow: debug_set_breakpoints → trigger request → debug_list_sessions (check halted_threads) → debug_get_variables.', toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), @@ -92,6 +94,13 @@ export function createDebugCaptureAtBreakpointTool( .boolean() .optional() .describe('If true, resume the thread after capturing the snapshot. Defaults to false.'), + trigger_url: z + .string() + .optional() + .describe( + 'URL to request after arming the breakpoint. The tool fires this HTTP GET in the background, then waits for the breakpoint to halt. ' + + 'This is the recommended approach — it avoids needing to coordinate a separate request while the tool blocks.', + ), }, async execute(args, context) { const registry = context.serverContext?.debugSessions; @@ -104,7 +113,6 @@ export function createDebugCaptureAtBreakpointTool( const scriptPath = resolveBreakpointPath(args.file, entry.sourceMapper, entry.cartridges); - // Set breakpoint (adds to existing) const bpInput: BreakpointInput = { script_path: scriptPath, line_number: args.line, @@ -126,6 +134,14 @@ export function createDebugCaptureAtBreakpointTool( script_path: scriptPath, }; + // Fire trigger URL in the background (it will hang when the breakpoint halts the thread) + let triggerPromise: Promise | undefined; + if (args.trigger_url) { + triggerPromise = fetch(args.trigger_url, {redirect: 'follow'}) + .then((r) => r.status) + .catch((): undefined => undefined); + } + // Wait for halt const haltedThread = entry.manager.getKnownThreads().find((t) => t.status === 'halted'); let thread: null | SdapiScriptThread = haltedThread ?? null; @@ -150,7 +166,6 @@ export function createDebugCaptureAtBreakpointTool( }; } - // Capture stack const threadDetail = await entry.manager.client.getThread(thread.id); const stack = threadDetail.call_stack.map((frame) => ({ index: frame.index, @@ -160,7 +175,6 @@ export function createDebugCaptureAtBreakpointTool( script_path: frame.location.script_path, })); - // Capture variables (top frame) const varsResult = await entry.manager.client.getVariables(thread.id, 0); const variables = varsResult.object_members.map((m) => ({ name: m.name, @@ -170,7 +184,6 @@ export function createDebugCaptureAtBreakpointTool( has_children: !PRIMITIVE_TYPES.has(m.type), })); - // Evaluate expressions (sequential — each must complete before the next on the debugger) const evaluations: Array<{expression: string; result: string}> = []; if (args.expressions) { for (const expr of args.expressions) { @@ -187,13 +200,14 @@ export function createDebugCaptureAtBreakpointTool( } } - // Optionally continue let autoContinued = false; if (args.auto_continue) { await entry.manager.resume(thread.id); autoContinued = true; } + const triggerStatus = triggerPromise ? await triggerPromise : undefined; + return { breakpoint: breakpointInfo, halted: true, @@ -202,6 +216,7 @@ export function createDebugCaptureAtBreakpointTool( variables, evaluations: evaluations.length > 0 ? evaluations : undefined, auto_continued: autoContinued, + trigger_status: triggerStatus, }; }, formatOutput: (output) => jsonResult(output), diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts index f4e9983e8..2ff73a1c5 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts @@ -15,6 +15,12 @@ interface ListSessionsOutput { hostname: string; client_id: string; halted_threads: number[]; + breakpoints: Array<{ + id: number; + file: null | string; + line: number; + script_path: string; + }>; created_at: string; last_activity_at: string; }>; @@ -28,9 +34,9 @@ export function createDebugListSessionsTool( { name: 'debug_list_sessions', description: - 'List all active script debugger sessions. ' + - 'Returns session IDs, connected hostnames, and any currently halted threads. ' + - 'Use this to discover existing sessions before creating a new one.', + 'List all active script debugger sessions with their breakpoints and halted threads. ' + + 'Use this to check session state: whether breakpoints are armed, which threads are halted, and whether you need to call debug_get_variables or debug_continue. ' + + 'This is the recommended way to poll for halted threads in the non-blocking debug workflow.', toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], inputSchema: {}, async execute(_args, context) { @@ -49,6 +55,12 @@ export function createDebugListSessionsTool( .getKnownThreads() .filter((t) => t.status === 'halted') .map((t) => t.id), + breakpoints: entry.breakpoints.map((bp) => ({ + id: bp.id, + file: entry.sourceMapper.toLocalPath(bp.script_path) ?? null, + line: bp.line_number, + script_path: bp.script_path, + })), created_at: new Date(entry.createdAt).toISOString(), last_activity_at: new Date(entry.lastActivityAt).toISOString(), })), diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts index eb508fb0b..80ad61f1c 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts @@ -16,15 +16,18 @@ interface SetBreakpointsInput { breakpoints: Array<{file: string; line: number; condition?: string}>; } +interface BreakpointResult { + id: number; + file: null | string; + line: number; + script_path: string; + verified: boolean; + condition?: string; +} + interface SetBreakpointsOutput { - breakpoints: Array<{ - id: number; - file: null | string; - line: number; - script_path: string; - verified: boolean; - condition?: string; - }>; + breakpoints: BreakpointResult[]; + warnings?: string[]; } export function createDebugSetBreakpointsTool( @@ -36,8 +39,8 @@ export function createDebugSetBreakpointsTool( name: 'debug_set_breakpoints', description: 'Set breakpoints in a debug session. Replaces all previously set breakpoints. ' + - 'Accepts local file paths which are mapped to server script paths via cartridge mappings. ' + - 'You can also pass server paths directly (starting with /).', + 'Accepts local file paths (mapped to server paths via cartridge discovery), cartridge-prefixed paths (e.g. app_storefront/cartridge/controllers/Cart.js), or server paths starting with /. ' + + 'Check the "verified" field and "warnings" in the response — if a path could not be mapped to a known cartridge, it will be flagged.', toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), @@ -65,12 +68,23 @@ export function createDebugSetBreakpointsTool( } const entry = registry.getSessionOrThrow(args.session_id); + const warnings: string[] = []; - const bpInputs: BreakpointInput[] = args.breakpoints.map((bp) => ({ - script_path: resolveBreakpointPath(bp.file, entry.sourceMapper, entry.cartridges), - line_number: bp.line, - condition: bp.condition, - })); + const bpInputs: BreakpointInput[] = args.breakpoints.map((bp) => { + const scriptPath = resolveBreakpointPath(bp.file, entry.sourceMapper, entry.cartridges); + const roundTrip = entry.sourceMapper.toLocalPath(scriptPath); + if (!roundTrip) { + warnings.push( + `"${bp.file}" resolved to server path "${scriptPath}" but could not be mapped back to a local file. ` + + `Verify this path exists on the instance.`, + ); + } + return { + script_path: scriptPath, + line_number: bp.line, + condition: bp.condition, + }; + }); const result = await entry.manager.setBreakpoints(bpInputs); entry.breakpoints = result; @@ -81,9 +95,10 @@ export function createDebugSetBreakpointsTool( file: entry.sourceMapper.toLocalPath(bp.script_path) ?? null, line: bp.line_number, script_path: bp.script_path, - verified: true, + verified: entry.sourceMapper.toLocalPath(bp.script_path) !== undefined, condition: bp.condition, })), + warnings: warnings.length > 0 ? warnings : undefined, }; }, formatOutput: (output) => jsonResult(output), diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts index 19fb54b2e..712c3af3a 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts @@ -26,6 +26,7 @@ interface StartSessionOutput { session_id: string; hostname: string; cartridges: string[]; + cartridge_mappings: Record; warnings: string[]; } @@ -105,10 +106,16 @@ export function createDebugStartSessionTool( const entry = registry.registerSession(hostname, clientId, manager, sourceMapper, cartridges); + const cartridgeMappings: Record = {}; + for (const c of cartridges) { + cartridgeMappings[c.name] = c.src; + } + return { session_id: entry.sessionId, hostname, cartridges: cartridges.map((c) => c.name), + cartridge_mappings: cartridgeMappings, warnings, }; }, diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts index 35a4bbd36..089f911dc 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts @@ -41,7 +41,8 @@ export function createDebugWaitForStopTool( description: 'Wait for a thread to halt at a breakpoint or step. ' + 'Returns immediately if a thread is already halted. ' + - 'Otherwise BLOCKS until a halt occurs or the timeout expires — the user or an external process must trigger a request on the instance while this tool is waiting.', + 'Otherwise BLOCKS until a halt occurs or the timeout expires. ' + + 'Preferred non-blocking alternative: after debug_set_breakpoints, trigger the request yourself, then use debug_list_sessions to check if halted_threads is non-empty before calling debug_get_stack/debug_get_variables.', toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), From d9653d9609454d85bbb0750a90ad24b35c3d54ba Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Sun, 3 May 2026 21:44:48 -0400 Subject: [PATCH 07/14] Fix resolveBreakpointPath treating absolute local paths as server paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Absolute local paths like /Users/.../app_mysite/cartridge/controllers/Loyalty.js start with / and were returned as-is (treated as server paths) before the source mapper had a chance to map them. Now the source mapper runs first — if it matches, the local path is correctly converted to a server path. The / passthrough only applies if the mapper doesn't match. --- .../src/operations/debug/resolve-path.ts | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/b2c-tooling-sdk/src/operations/debug/resolve-path.ts b/packages/b2c-tooling-sdk/src/operations/debug/resolve-path.ts index c04e982ea..ae8c2c12a 100644 --- a/packages/b2c-tooling-sdk/src/operations/debug/resolve-path.ts +++ b/packages/b2c-tooling-sdk/src/operations/debug/resolve-path.ts @@ -12,9 +12,9 @@ import type {CartridgeMapping} from '../code/cartridges.js'; * Resolve a user-provided file path to an SDAPI script_path. * * Accepts: - * - Server path: `/app_storefront/cartridge/controllers/Cart.js` (passed through) * - Absolute local path: `/Users/.../app_storefront/cartridge/controllers/Cart.js` * - Relative local path: `./cartridges/app_storefront/cartridge/controllers/Cart.js` + * - Server path: `/app_storefront/cartridge/controllers/Cart.js` (passed through if not a local match) * - Cartridge-name-prefixed: `app_storefront/cartridge/controllers/Cart.js` (prefixed with /) * * Returns the SDAPI script_path (e.g. `/app_storefront/cartridge/controllers/Cart.js`). @@ -25,18 +25,21 @@ export function resolveBreakpointPath( sourceMapper: SourceMapper, cartridges: CartridgeMapping[], ): string { - if (input.startsWith('/')) { - return input; - } - - // Try direct source mapper (handles absolute and resolvable relative paths) + // Always try source mapper first — handles both absolute and relative local paths const mapped = sourceMapper.toServerPath(input); if (mapped) return mapped; - // Try resolving relative to cwd + // Try resolving relative to cwd (for paths like ./cartridges/app_mysite/...) const resolved = path.resolve(input); - const mappedResolved = sourceMapper.toServerPath(resolved); - if (mappedResolved) return mappedResolved; + if (resolved !== input) { + const mappedResolved = sourceMapper.toServerPath(resolved); + if (mappedResolved) return mappedResolved; + } + + // If it starts with / and the source mapper didn't match, treat as server path + if (input.startsWith('/')) { + return input; + } // Check if the input starts with a known cartridge name — treat as server path missing leading / const normalized = input.split(path.sep).join('/'); @@ -46,14 +49,15 @@ export function resolveBreakpointPath( } const cartridgeNames = cartridges.map((c) => c.name).join(', '); - const hint = cartridges.length > 0 - ? `Known cartridges: ${cartridgeNames}\n` + - `Accepted forms:\n` + - ` /cartridge_name/cartridge/path/to/file.js (server path)\n` + - ` cartridge_name/cartridge/path/to/file.js (server path without leading /)\n` + - ` ./path/to/cartridge_name/cartridge/file.js (relative local path)\n` + - ` /absolute/path/to/cartridge_name/file.js (absolute local path)` - : 'No cartridges discovered. Use a server path: /cartridge_name/cartridge/path/file.js'; + const hint = + cartridges.length > 0 + ? `Known cartridges: ${cartridgeNames}\n` + + `Accepted forms:\n` + + ` /cartridge_name/cartridge/path/to/file.js (server path)\n` + + ` cartridge_name/cartridge/path/to/file.js (server path without leading /)\n` + + ` ./path/to/cartridge_name/cartridge/file.js (relative local path)\n` + + ` /absolute/path/to/cartridge_name/file.js (absolute local path)` + : 'No cartridges discovered. Use a server path: /cartridge_name/cartridge/path/file.js'; throw new Error(`Cannot resolve "${input}" to a server script path.\n${hint}`); } From 8da16e3ebe6f1361332c1c2c469efebcbbea3358 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Tue, 5 May 2026 07:36:33 -0400 Subject: [PATCH 08/14] Exclude diagnostics tools from MCP coverage threshold The new debug tools have no unit tests yet (they require mocking the DebugSessionManager and SDAPI). Excluding from coverage until tests are added to avoid blocking the draft PR. --- packages/b2c-dx-mcp/.c8rc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/b2c-dx-mcp/.c8rc.json b/packages/b2c-dx-mcp/.c8rc.json index eb81059e5..a3debbf43 100644 --- a/packages/b2c-dx-mcp/.c8rc.json +++ b/packages/b2c-dx-mcp/.c8rc.json @@ -1,7 +1,7 @@ { "all": true, "src": ["src"], - "exclude": ["test/**", "**/*.d.ts", "**/index.ts", "**/site-theming/types.ts"], + "exclude": ["test/**", "**/*.d.ts", "**/index.ts", "**/site-theming/types.ts", "**/tools/diagnostics/**"], "reporter": ["text", "text-summary", "html", "lcov"], "report-dir": "coverage", "check-coverage": true, From 0f83b48b0dc8fffd342df12c0b9d8f912d0a2c91 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Tue, 5 May 2026 07:40:04 -0400 Subject: [PATCH 09/14] Add tests for diagnostics tools (session registry + tool handlers) Tests cover DebugSessionRegistry (register, duplicate rejection, get, destroy, halt waiter cleanup) and tool handlers (list_sessions, end_session, continue, get_stack, evaluate). Diagnostics tools excluded from coverage threshold until full coverage is added. --- .../tools/diagnostics/debug-tools.test.ts | 223 ++++++++++++++++++ .../diagnostics/session-registry.test.ts | 203 ++++++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts create mode 100644 packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts diff --git a/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts b/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts new file mode 100644 index 000000000..0acbc3a31 --- /dev/null +++ b/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Services} from '../../../src/services.js'; +import {ServerContext} from '../../../src/server-context.js'; +import {createMockResolvedConfig} from '../../test-helpers.js'; +import {createDebugListSessionsTool} from '../../../src/tools/diagnostics/debug-list-sessions.js'; +import {createDebugEndSessionTool} from '../../../src/tools/diagnostics/debug-end-session.js'; +import {createDebugContinueTool} from '../../../src/tools/diagnostics/debug-continue.js'; +import {createDebugGetStackTool} from '../../../src/tools/diagnostics/debug-get-stack.js'; +import {createDebugEvaluateTool} from '../../../src/tools/diagnostics/debug-evaluate.js'; +import type {DebugSessionManager, SourceMapper} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import type {ToolResult} from '../../../src/utils/index.js'; + +function getResultJson(result: ToolResult): T { + const text = result.content[0]; + if (text?.type !== 'text') throw new Error('Expected text content'); + return JSON.parse(text.text) as T; +} + +function getResultText(result: ToolResult): string { + const text = result.content[0]; + if (text?.type !== 'text') throw new Error('Expected text content'); + return text.text; +} + +function createMockManager(overrides?: Record): DebugSessionManager { + return { + client: { + getThread: sinon.stub().resolves({ + id: 1, + status: 'halted', + call_stack: [ + {index: 0, location: {function_name: 'show', line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}}, + ], + }), + getVariables: sinon.stub().resolves({object_members: [], count: 0, start: 0, total: 0, _v: '2.0'}), + getMembers: sinon.stub().resolves({object_members: [], count: 0, start: 0, total: 0, _v: '2.0'}), + evaluate: sinon.stub().resolves({_v: '2.0', expression: 'x', result: '42'}), + deleteBreakpoints: sinon.stub().resolves(), + }, + connect: sinon.stub().resolves(), + disconnect: sinon.stub().resolves(), + setBreakpoints: sinon.stub().resolves([]), + resume: sinon.stub().resolves(), + stepOver: sinon.stub().resolves(), + stepInto: sinon.stub().resolves(), + stepOut: sinon.stub().resolves(), + getKnownThreads: sinon.stub().returns([]), + ...overrides, + } as unknown as DebugSessionManager; +} + +function createMockSourceMapper(): SourceMapper { + return { + toServerPath: sinon.stub().returns(undefined), + toLocalPath: sinon.stub().callsFake((p: string) => (p.startsWith('/app_test') ? `/local${p}` : undefined)), + }; +} + +function createServices(): Services { + return new Services({ + resolvedConfig: createMockResolvedConfig({hostname: 'test.example.com', username: 'user', password: 'pass'}), + }); +} + +describe('tools/diagnostics', () => { + let serverContext: ServerContext; + let loadServices: () => Services; + + beforeEach(() => { + serverContext = new ServerContext(); + loadServices = () => createServices(); + }); + + afterEach(async () => { + await serverContext.destroyAll(); + }); + + describe('debug_list_sessions', () => { + it('should have correct metadata', () => { + const tool = createDebugListSessionsTool(loadServices, serverContext); + expect(tool.name).to.equal('debug_list_sessions'); + expect(tool.toolsets).to.include('CARTRIDGES'); + expect(tool.toolsets).to.include('STOREFRONTNEXT'); + expect(tool.toolsets).to.include('SCAPI'); + }); + + it('should return empty sessions array when none exist', async () => { + const tool = createDebugListSessionsTool(loadServices, serverContext); + const result = await tool.handler({}); + const json = getResultJson<{sessions: unknown[]}>(result); + expect(json.sessions).to.deep.equal([]); + }); + + it('should list sessions with breakpoints and halted threads', async () => { + const manager = createMockManager({ + getKnownThreads: sinon.stub().returns([{id: 5, status: 'halted', call_stack: []}]), + }); + const sourceMapper = createMockSourceMapper(); + const entry = serverContext.debugSessions.registerSession('host.example.com', 'c1', manager, sourceMapper, []); + entry.breakpoints = [{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]; + + const tool = createDebugListSessionsTool(loadServices, serverContext); + const result = await tool.handler({}); + const json = getResultJson<{sessions: Array<{session_id: string; halted_threads: number[]; breakpoints: unknown[]}>}>(result); + + expect(json.sessions).to.have.lengthOf(1); + expect(json.sessions[0].session_id).to.equal(entry.sessionId); + expect(json.sessions[0].halted_threads).to.deep.equal([5]); + expect(json.sessions[0].breakpoints).to.have.lengthOf(1); + }); + }); + + describe('debug_end_session', () => { + it('should disconnect and remove the session', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugEndSessionTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId}); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{status: string}>(result); + expect(json.status).to.equal('disconnected'); + expect(serverContext.debugSessions.getSession(entry.sessionId)).to.be.undefined; + expect((manager.disconnect as sinon.SinonStub).calledOnce).to.be.true; + }); + + it('should clear breakpoints when requested', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugEndSessionTool(loadServices, serverContext); + await tool.handler({session_id: entry.sessionId, clear_breakpoints: true}); + + expect((manager.client.deleteBreakpoints as sinon.SinonStub).calledOnce).to.be.true; + }); + + it('should return error for unknown session', async () => { + const tool = createDebugEndSessionTool(loadServices, serverContext); + const result = await tool.handler({session_id: 'nonexistent'}); + + expect(result.isError).to.be.true; + expect(getResultText(result)).to.include('No debug session found'); + }); + }); + + describe('debug_continue', () => { + it('should resume the specified thread', async () => { + const manager = createMockManager(); + serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.listSessions()[0]; + + const tool = createDebugContinueTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, thread_id: 5}); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{thread_id: number; status: string}>(result); + expect(json.thread_id).to.equal(5); + expect(json.status).to.equal('resumed'); + expect((manager.resume as sinon.SinonStub).calledWith(5)).to.be.true; + }); + }); + + describe('debug_get_stack', () => { + it('should return mapped stack frames', async () => { + const manager = createMockManager(); + const sourceMapper = createMockSourceMapper(); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, sourceMapper, []); + + const tool = createDebugGetStackTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, thread_id: 1}); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{frames: Array<{function_name: string; file: string; line: number}>}>(result); + expect(json.frames).to.have.lengthOf(1); + expect(json.frames[0].function_name).to.equal('show'); + expect(json.frames[0].line).to.equal(42); + expect(json.frames[0].file).to.equal('/local/app_test/cartridge/controllers/Cart.js'); + }); + }); + + describe('debug_evaluate', () => { + it('should evaluate expression and return result', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugEvaluateTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, thread_id: 1, expression: 'x'}); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{expression: string; result: string}>(result); + expect(json.expression).to.equal('x'); + expect(json.result).to.equal('42'); + }); + + it('should use frame_index 0 by default', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugEvaluateTool(loadServices, serverContext); + await tool.handler({session_id: entry.sessionId, thread_id: 1, expression: 'x'}); + + expect((manager.client.evaluate as sinon.SinonStub).calledWith(1, 0, 'x')).to.be.true; + }); + + it('should use specified frame_index', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugEvaluateTool(loadServices, serverContext); + await tool.handler({session_id: entry.sessionId, thread_id: 1, frame_index: 2, expression: 'y'}); + + expect((manager.client.evaluate as sinon.SinonStub).calledWith(1, 2, 'y')).to.be.true; + }); + }); +}); diff --git a/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts b/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts new file mode 100644 index 000000000..8b2a3eaf6 --- /dev/null +++ b/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {DebugSessionRegistry} from '../../../src/tools/diagnostics/session-registry.js'; +import type {DebugSessionManager, SourceMapper} from '@salesforce/b2c-tooling-sdk/operations/debug'; + +function createMockManager(overrides?: Partial): DebugSessionManager { + return { + client: {} as any, + connect: sinon.stub().resolves(), + disconnect: sinon.stub().resolves(), + setBreakpoints: sinon.stub().resolves([]), + resume: sinon.stub().resolves(), + stepOver: sinon.stub().resolves(), + stepInto: sinon.stub().resolves(), + stepOut: sinon.stub().resolves(), + getKnownThreads: sinon.stub().returns([]), + ...overrides, + } as unknown as DebugSessionManager; +} + +function createMockSourceMapper(): SourceMapper { + return { + toServerPath: sinon.stub().returns(undefined), + toLocalPath: sinon.stub().returns(undefined), + }; +} + +describe('DebugSessionRegistry', () => { + let registry: DebugSessionRegistry; + + beforeEach(() => { + registry = new DebugSessionRegistry(); + }); + + afterEach(async () => { + await registry.destroyAll(); + }); + + describe('registerSession', () => { + it('should register a session and return an entry with a UUID', () => { + const manager = createMockManager(); + const sourceMapper = createMockSourceMapper(); + + const entry = registry.registerSession('host.example.com', 'client-1', manager, sourceMapper, []); + + expect(entry.sessionId).to.be.a('string'); + expect(entry.sessionId).to.match(/^[\da-f-]{36}$/); + expect(entry.hostname).to.equal('host.example.com'); + expect(entry.clientId).to.equal('client-1'); + expect(entry.manager).to.equal(manager); + expect(entry.sourceMapper).to.equal(sourceMapper); + expect(entry.breakpoints).to.deep.equal([]); + expect(entry.haltWaiters).to.deep.equal([]); + }); + + it('should reject duplicate hostname:clientId pairs', () => { + const manager = createMockManager(); + const sourceMapper = createMockSourceMapper(); + + registry.registerSession('host.example.com', 'client-1', manager, sourceMapper, []); + + expect(() => { + registry.registerSession('host.example.com', 'client-1', createMockManager(), createMockSourceMapper(), []); + }).to.throw(/already exists.*client-1/); + }); + + it('should allow different client IDs on same host', () => { + const sourceMapper = createMockSourceMapper(); + + registry.registerSession('host.example.com', 'client-1', createMockManager(), sourceMapper, []); + const entry2 = registry.registerSession('host.example.com', 'client-2', createMockManager(), sourceMapper, []); + + expect(entry2.sessionId).to.be.a('string'); + }); + + it('should allow same client ID on different hosts', () => { + const sourceMapper = createMockSourceMapper(); + + registry.registerSession('host1.example.com', 'client-1', createMockManager(), sourceMapper, []); + const entry2 = registry.registerSession('host2.example.com', 'client-1', createMockManager(), sourceMapper, []); + + expect(entry2.sessionId).to.be.a('string'); + }); + }); + + describe('getSession', () => { + it('should return undefined for unknown session ID', () => { + expect(registry.getSession('nonexistent')).to.be.undefined; + }); + + it('should return the session entry', () => { + const entry = registry.registerSession('host.example.com', 'c', createMockManager(), createMockSourceMapper(), []); + expect(registry.getSession(entry.sessionId)).to.equal(entry); + }); + }); + + describe('getSessionOrThrow', () => { + it('should throw for unknown session ID', () => { + expect(() => registry.getSessionOrThrow('nonexistent')).to.throw(/No debug session found/); + }); + + it('should return entry and update lastActivityAt', () => { + const entry = registry.registerSession('host.example.com', 'c', createMockManager(), createMockSourceMapper(), []); + const before = entry.lastActivityAt; + + // Small delay to ensure timestamp changes + const result = registry.getSessionOrThrow(entry.sessionId); + + expect(result).to.equal(entry); + expect(result.lastActivityAt).to.be.at.least(before); + }); + }); + + describe('findByHostAndClientId', () => { + it('should return undefined when no match', () => { + expect(registry.findByHostAndClientId('nope', 'nope')).to.be.undefined; + }); + + it('should find matching entry', () => { + const entry = registry.registerSession('host.example.com', 'c', createMockManager(), createMockSourceMapper(), []); + expect(registry.findByHostAndClientId('host.example.com', 'c')).to.equal(entry); + }); + }); + + describe('listSessions', () => { + it('should return empty array when no sessions', () => { + expect(registry.listSessions()).to.deep.equal([]); + }); + + it('should return all sessions', () => { + registry.registerSession('h1', 'c1', createMockManager(), createMockSourceMapper(), []); + registry.registerSession('h2', 'c2', createMockManager(), createMockSourceMapper(), []); + + const list = registry.listSessions(); + expect(list).to.have.lengthOf(2); + }); + }); + + describe('destroySession', () => { + it('should disconnect the manager', async () => { + const manager = createMockManager(); + const entry = registry.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + await registry.destroySession(entry.sessionId); + + expect((manager.disconnect as sinon.SinonStub).calledOnce).to.be.true; + expect(registry.getSession(entry.sessionId)).to.be.undefined; + }); + + it('should reject pending halt waiters', async () => { + const entry = registry.registerSession('host', 'c', createMockManager(), createMockSourceMapper(), []); + + const waiterPromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve('timeout'), 5000); + entry.haltWaiters.push({ + resolve: () => { + clearTimeout(timer); + resolve('resolved'); + }, + reject: (err) => { + clearTimeout(timer); + reject(err); + }, + timer, + }); + }); + + await registry.destroySession(entry.sessionId); + + try { + await waiterPromise; + expect.fail('Should have rejected'); + } catch (err) { + expect((err as Error).message).to.equal('Debug session ended'); + } + }); + + it('should be a no-op for unknown session ID', async () => { + await registry.destroySession('nonexistent'); // Should not throw + }); + }); + + describe('destroyAll', () => { + it('should destroy all sessions', async () => { + const m1 = createMockManager(); + const m2 = createMockManager(); + registry.registerSession('h1', 'c1', m1, createMockSourceMapper(), []); + registry.registerSession('h2', 'c2', m2, createMockSourceMapper(), []); + + await registry.destroyAll(); + + expect((m1.disconnect as sinon.SinonStub).calledOnce).to.be.true; + expect((m2.disconnect as sinon.SinonStub).calledOnce).to.be.true; + expect(registry.listSessions()).to.deep.equal([]); + }); + }); +}); From a2028bda0bc9229778a60e0d829bb6867b3eb680 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Tue, 5 May 2026 08:20:39 -0400 Subject: [PATCH 10/14] Fix lint errors in diagnostics test files --- .../tools/diagnostics/debug-tools.test.ts | 9 +++-- .../diagnostics/session-registry.test.ts | 36 ++++++++++++++----- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts b/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts index 0acbc3a31..1b290a5ce 100644 --- a/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts +++ b/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts @@ -36,7 +36,10 @@ function createMockManager(overrides?: Record): DebugSessionMan id: 1, status: 'halted', call_stack: [ - {index: 0, location: {function_name: 'show', line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}}, + { + index: 0, + location: {function_name: 'show', line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}, + }, ], }), getVariables: sinon.stub().resolves({object_members: [], count: 0, start: 0, total: 0, _v: '2.0'}), @@ -108,7 +111,9 @@ describe('tools/diagnostics', () => { const tool = createDebugListSessionsTool(loadServices, serverContext); const result = await tool.handler({}); - const json = getResultJson<{sessions: Array<{session_id: string; halted_threads: number[]; breakpoints: unknown[]}>}>(result); + const json = getResultJson<{ + sessions: Array<{session_id: string; halted_threads: number[]; breakpoints: unknown[]}>; + }>(result); expect(json.sessions).to.have.lengthOf(1); expect(json.sessions[0].session_id).to.equal(entry.sessionId); diff --git a/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts b/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts index 8b2a3eaf6..baba998c2 100644 --- a/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts +++ b/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts @@ -11,7 +11,7 @@ import type {DebugSessionManager, SourceMapper} from '@salesforce/b2c-tooling-sd function createMockManager(overrides?: Partial): DebugSessionManager { return { - client: {} as any, + client: {} as unknown, connect: sinon.stub().resolves(), disconnect: sinon.stub().resolves(), setBreakpoints: sinon.stub().resolves([]), @@ -95,7 +95,13 @@ describe('DebugSessionRegistry', () => { }); it('should return the session entry', () => { - const entry = registry.registerSession('host.example.com', 'c', createMockManager(), createMockSourceMapper(), []); + const entry = registry.registerSession( + 'host.example.com', + 'c', + createMockManager(), + createMockSourceMapper(), + [], + ); expect(registry.getSession(entry.sessionId)).to.equal(entry); }); }); @@ -106,7 +112,13 @@ describe('DebugSessionRegistry', () => { }); it('should return entry and update lastActivityAt', () => { - const entry = registry.registerSession('host.example.com', 'c', createMockManager(), createMockSourceMapper(), []); + const entry = registry.registerSession( + 'host.example.com', + 'c', + createMockManager(), + createMockSourceMapper(), + [], + ); const before = entry.lastActivityAt; // Small delay to ensure timestamp changes @@ -123,7 +135,13 @@ describe('DebugSessionRegistry', () => { }); it('should find matching entry', () => { - const entry = registry.registerSession('host.example.com', 'c', createMockManager(), createMockSourceMapper(), []); + const entry = registry.registerSession( + 'host.example.com', + 'c', + createMockManager(), + createMockSourceMapper(), + [], + ); expect(registry.findByHostAndClientId('host.example.com', 'c')).to.equal(entry); }); }); @@ -159,13 +177,13 @@ describe('DebugSessionRegistry', () => { const waiterPromise = new Promise((resolve, reject) => { const timer = setTimeout(() => resolve('timeout'), 5000); entry.haltWaiters.push({ - resolve: () => { + resolve() { clearTimeout(timer); resolve('resolved'); }, - reject: (err) => { + reject(e) { clearTimeout(timer); - reject(err); + reject(e); }, timer, }); @@ -176,8 +194,8 @@ describe('DebugSessionRegistry', () => { try { await waiterPromise; expect.fail('Should have rejected'); - } catch (err) { - expect((err as Error).message).to.equal('Debug session ended'); + } catch (error) { + expect((error as Error).message).to.equal('Debug session ended'); } }); From 9d72bf053e64bc57623f8cb511575850fcf7967c Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Tue, 5 May 2026 20:06:30 -0400 Subject: [PATCH 11/14] Remove debugger tools from STOREFRONTNEXT toolset --- .../src/tools/diagnostics/debug-capture-at-breakpoint.ts | 2 +- packages/b2c-dx-mcp/src/tools/diagnostics/debug-continue.ts | 2 +- packages/b2c-dx-mcp/src/tools/diagnostics/debug-end-session.ts | 2 +- packages/b2c-dx-mcp/src/tools/diagnostics/debug-evaluate.ts | 2 +- packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-stack.ts | 2 +- .../b2c-dx-mcp/src/tools/diagnostics/debug-get-variables.ts | 2 +- .../b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts | 2 +- .../b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts | 2 +- .../b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts | 2 +- packages/b2c-dx-mcp/src/tools/diagnostics/debug-step.ts | 2 +- .../b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts | 2 +- packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts | 1 - 12 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts index da9f06357..775a76a82 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts @@ -74,7 +74,7 @@ export function createDebugCaptureAtBreakpointTool( 'Use trigger_url to have the tool fire the request itself (recommended) — this avoids needing to coordinate a separate request while the tool blocks. ' + 'Without trigger_url, the tool BLOCKS until the breakpoint is hit or timeout expires and requires the user to trigger a request externally. ' + 'For more control, use the non-blocking workflow: debug_set_breakpoints → trigger request → debug_list_sessions (check halted_threads) → debug_get_variables.', - toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), file: z.string().describe('Local file path or server script path for the breakpoint.'), diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-continue.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-continue.ts index 7c1009819..285f5ba93 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-continue.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-continue.ts @@ -29,7 +29,7 @@ export function createDebugContinueTool( name: 'debug_continue', description: 'Resume execution of a halted thread. The thread continues until the next breakpoint or request completion.', - toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), thread_id: z.number().int().describe('Thread ID of the halted thread to resume.'), diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-end-session.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-end-session.ts index 8026320a6..12b3fcf77 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-end-session.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-end-session.ts @@ -31,7 +31,7 @@ export function createDebugEndSessionTool( 'End a script debugger session. ' + 'Disconnects from the SDAPI, stops polling, and cleans up resources. ' + 'Optionally clears all breakpoints before disconnecting.', - toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), clear_breakpoints: z diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-evaluate.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-evaluate.ts index 26c2bf029..03afbf148 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-evaluate.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-evaluate.ts @@ -33,7 +33,7 @@ export function createDebugEvaluateTool( 'Evaluate an expression in the context of a halted thread and stack frame. ' + 'WARNING: Expressions can have side effects (modify variables, call functions). ' + 'Use with care on production-like instances.', - toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), thread_id: z.number().int().describe('Thread ID from debug_wait_for_stop.'), diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-stack.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-stack.ts index 7459ee638..6eaaee924 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-stack.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-stack.ts @@ -36,7 +36,7 @@ export function createDebugGetStackTool( description: 'Get the call stack for a halted thread. ' + 'Returns stack frames with mapped local file paths and server script paths.', - toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), thread_id: z.number().int().describe('Thread ID from debug_wait_for_stop.'), diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-variables.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-variables.ts index 10749f4df..f6a18a013 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-variables.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-variables.ts @@ -43,7 +43,7 @@ export function createDebugGetVariablesTool( 'By default returns top-frame local variables. ' + 'Use scope to filter (local, closure, global). ' + 'Use object_path to drill into nested objects.', - toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), thread_id: z.number().int().describe('Thread ID from debug_wait_for_stop.'), diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts index 2ff73a1c5..f3705cadb 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts @@ -37,7 +37,7 @@ export function createDebugListSessionsTool( 'List all active script debugger sessions with their breakpoints and halted threads. ' + 'Use this to check session state: whether breakpoints are armed, which threads are halted, and whether you need to call debug_get_variables or debug_continue. ' + 'This is the recommended way to poll for halted threads in the non-blocking debug workflow.', - toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: {}, async execute(_args, context) { const registry = context.serverContext?.debugSessions; diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts index 80ad61f1c..91a1128ab 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts @@ -41,7 +41,7 @@ export function createDebugSetBreakpointsTool( 'Set breakpoints in a debug session. Replaces all previously set breakpoints. ' + 'Accepts local file paths (mapped to server paths via cartridge discovery), cartridge-prefixed paths (e.g. app_storefront/cartridge/controllers/Cart.js), or server paths starting with /. ' + 'Check the "verified" field and "warnings" in the response — if a path could not be mapped to a known cartridge, it will be flagged.', - toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), breakpoints: z diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts index 712c3af3a..3fd202c8d 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts @@ -43,7 +43,7 @@ export function createDebugStartSessionTool( 'WARNING: Debug sessions can halt remote request threads on the instance. ' + 'Use debug_end_session to cleanly disconnect when done. ' + 'Requires Basic auth credentials (username/password).', - toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { cartridge_directory: z .string() diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-step.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-step.ts index cedef805d..aeed5f840 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-step.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-step.ts @@ -32,7 +32,7 @@ function createStepTool( { name: `debug_${action}`, description: description + ' Follow with debug_wait_for_stop to see where execution landed.', - toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), thread_id: z.number().int().describe('Thread ID of the halted thread to step.'), diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts index 089f911dc..2e2cd343c 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts @@ -43,7 +43,7 @@ export function createDebugWaitForStopTool( 'Returns immediately if a thread is already halted. ' + 'Otherwise BLOCKS until a halt occurs or the timeout expires. ' + 'Preferred non-blocking alternative: after debug_set_breakpoints, trigger the request yourself, then use debug_list_sessions to check if halted_threads is non-empty before calling debug_get_stack/debug_get_variables.', - toolsets: ['CARTRIDGES', 'SCAPI', 'STOREFRONTNEXT'], + toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), timeout_ms: z diff --git a/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts b/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts index 1b290a5ce..b34b32cc8 100644 --- a/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts +++ b/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts @@ -90,7 +90,6 @@ describe('tools/diagnostics', () => { const tool = createDebugListSessionsTool(loadServices, serverContext); expect(tool.name).to.equal('debug_list_sessions'); expect(tool.toolsets).to.include('CARTRIDGES'); - expect(tool.toolsets).to.include('STOREFRONTNEXT'); expect(tool.toolsets).to.include('SCAPI'); }); From b1276e4502a4cec2ade173b7b368e3b3fdb5cc3b Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Tue, 5 May 2026 23:19:09 -0400 Subject: [PATCH 12/14] Add comprehensive tests for diagnostics tools, remove coverage exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers all 13 debug tool handlers including set_breakpoints (path mapping + warnings), get_variables (scope filtering, object_path, truncation), wait_for_stop (immediate halt, timeout, waiter resolution), capture_at_breakpoint (full orchestration, trigger_url, eval errors, timeout), start_session (real fixture cartridge + stubbed manager), and all step_* variants. Registry tests cover idle cleanup and disconnect error swallowing. Coverage is now 99.81% statements/lines, 94.65% branches, 98.94% functions — meeting all thresholds without excluding diagnostics. --- packages/b2c-dx-mcp/.c8rc.json | 2 +- .../tools/diagnostics/debug-tools.test.ts | 738 +++++++++++++++++- .../diagnostics/session-registry.test.ts | 33 + 3 files changed, 762 insertions(+), 11 deletions(-) diff --git a/packages/b2c-dx-mcp/.c8rc.json b/packages/b2c-dx-mcp/.c8rc.json index a3debbf43..eb81059e5 100644 --- a/packages/b2c-dx-mcp/.c8rc.json +++ b/packages/b2c-dx-mcp/.c8rc.json @@ -1,7 +1,7 @@ { "all": true, "src": ["src"], - "exclude": ["test/**", "**/*.d.ts", "**/index.ts", "**/site-theming/types.ts", "**/tools/diagnostics/**"], + "exclude": ["test/**", "**/*.d.ts", "**/index.ts", "**/site-theming/types.ts"], "reporter": ["text", "text-summary", "html", "lcov"], "report-dir": "coverage", "check-coverage": true, diff --git a/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts b/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts index b34b32cc8..e094190cd 100644 --- a/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts +++ b/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts @@ -4,6 +4,9 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; import {expect} from 'chai'; import sinon from 'sinon'; import {Services} from '../../../src/services.js'; @@ -14,7 +17,14 @@ import {createDebugEndSessionTool} from '../../../src/tools/diagnostics/debug-en import {createDebugContinueTool} from '../../../src/tools/diagnostics/debug-continue.js'; import {createDebugGetStackTool} from '../../../src/tools/diagnostics/debug-get-stack.js'; import {createDebugEvaluateTool} from '../../../src/tools/diagnostics/debug-evaluate.js'; -import type {DebugSessionManager, SourceMapper} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {createDebugGetVariablesTool} from '../../../src/tools/diagnostics/debug-get-variables.js'; +import {createDebugSetBreakpointsTool} from '../../../src/tools/diagnostics/debug-set-breakpoints.js'; +import {createDebugStepTools} from '../../../src/tools/diagnostics/debug-step.js'; +import {createDebugWaitForStopTool} from '../../../src/tools/diagnostics/debug-wait-for-stop.js'; +import {createDebugCaptureAtBreakpointTool} from '../../../src/tools/diagnostics/debug-capture-at-breakpoint.js'; +import {createDebugStartSessionTool} from '../../../src/tools/diagnostics/debug-start-session.js'; +import {DebugSessionManager} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import type {SourceMapper} from '@salesforce/b2c-tooling-sdk/operations/debug'; import type {ToolResult} from '../../../src/utils/index.js'; function getResultJson(result: ToolResult): T { @@ -29,7 +39,9 @@ function getResultText(result: ToolResult): string { return text.text; } -function createMockManager(overrides?: Record): DebugSessionManager { +type MockDebugSessionManager = InstanceType; + +function createMockManager(overrides?: Record): MockDebugSessionManager { return { client: { getThread: sinon.stub().resolves({ @@ -42,8 +54,24 @@ function createMockManager(overrides?: Record): DebugSessionMan }, ], }), - getVariables: sinon.stub().resolves({object_members: [], count: 0, start: 0, total: 0, _v: '2.0'}), - getMembers: sinon.stub().resolves({object_members: [], count: 0, start: 0, total: 0, _v: '2.0'}), + getVariables: sinon.stub().resolves({ + object_members: [ + {name: 'x', type: 'number', value: '42', scope: 'local'}, + {name: 'obj', type: 'Object', value: '[object Object]', scope: 'local'}, + {name: 'g', type: 'string', value: 'hi', scope: 'global'}, + ], + count: 3, + start: 0, + total: 3, + _v: '2.0', + }), + getMembers: sinon.stub().resolves({ + object_members: [{name: 'foo', type: 'string', value: 'bar'}], + count: 1, + start: 0, + total: 1, + _v: '2.0', + }), evaluate: sinon.stub().resolves({_v: '2.0', expression: 'x', result: '42'}), deleteBreakpoints: sinon.stub().resolves(), }, @@ -56,12 +84,18 @@ function createMockManager(overrides?: Record): DebugSessionMan stepOut: sinon.stub().resolves(), getKnownThreads: sinon.stub().returns([]), ...overrides, - } as unknown as DebugSessionManager; + } as unknown as MockDebugSessionManager; } function createMockSourceMapper(): SourceMapper { return { - toServerPath: sinon.stub().returns(undefined), + toServerPath: sinon.stub().callsFake((p: string) => { + if (p.includes('/app_test/')) { + const idx = p.indexOf('/app_test/'); + return p.slice(idx); + } + return undefined; + }), toLocalPath: sinon.stub().callsFake((p: string) => (p.startsWith('/app_test') ? `/local${p}` : undefined)), }; } @@ -119,6 +153,13 @@ describe('tools/diagnostics', () => { expect(json.sessions[0].halted_threads).to.deep.equal([5]); expect(json.sessions[0].breakpoints).to.have.lengthOf(1); }); + + it('should error when server context is missing', async () => { + const tool = createDebugListSessionsTool(loadServices, undefined); + const result = await tool.handler({}); + expect(result.isError).to.be.true; + expect(getResultText(result)).to.include('Debug session registry not available'); + }); }); describe('debug_end_session', () => { @@ -146,6 +187,17 @@ describe('tools/diagnostics', () => { expect((manager.client.deleteBreakpoints as sinon.SinonStub).calledOnce).to.be.true; }); + it('should handle deleteBreakpoints failure silently', async () => { + const manager = createMockManager(); + (manager.client.deleteBreakpoints as sinon.SinonStub).rejects(new Error('SDAPI down')); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugEndSessionTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, clear_breakpoints: true}); + + expect(result.isError).to.be.undefined; + }); + it('should return error for unknown session', async () => { const tool = createDebugEndSessionTool(loadServices, serverContext); const result = await tool.handler({session_id: 'nonexistent'}); @@ -153,13 +205,18 @@ describe('tools/diagnostics', () => { expect(result.isError).to.be.true; expect(getResultText(result)).to.include('No debug session found'); }); + + it('should error when server context is missing', async () => { + const tool = createDebugEndSessionTool(loadServices, undefined); + const result = await tool.handler({session_id: 'anything'}); + expect(result.isError).to.be.true; + }); }); describe('debug_continue', () => { it('should resume the specified thread', async () => { const manager = createMockManager(); - serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); - const entry = serverContext.debugSessions.listSessions()[0]; + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); const tool = createDebugContinueTool(loadServices, serverContext); const result = await tool.handler({session_id: entry.sessionId, thread_id: 5}); @@ -170,13 +227,18 @@ describe('tools/diagnostics', () => { expect(json.status).to.equal('resumed'); expect((manager.resume as sinon.SinonStub).calledWith(5)).to.be.true; }); + + it('should error when server context is missing', async () => { + const tool = createDebugContinueTool(loadServices, undefined); + const result = await tool.handler({session_id: 'x', thread_id: 1}); + expect(result.isError).to.be.true; + }); }); describe('debug_get_stack', () => { it('should return mapped stack frames', async () => { const manager = createMockManager(); - const sourceMapper = createMockSourceMapper(); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, sourceMapper, []); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); const tool = createDebugGetStackTool(loadServices, serverContext); const result = await tool.handler({session_id: entry.sessionId, thread_id: 1}); @@ -188,6 +250,12 @@ describe('tools/diagnostics', () => { expect(json.frames[0].line).to.equal(42); expect(json.frames[0].file).to.equal('/local/app_test/cartridge/controllers/Cart.js'); }); + + it('should error when server context is missing', async () => { + const tool = createDebugGetStackTool(loadServices, undefined); + const result = await tool.handler({session_id: 'x', thread_id: 1}); + expect(result.isError).to.be.true; + }); }); describe('debug_evaluate', () => { @@ -223,5 +291,655 @@ describe('tools/diagnostics', () => { expect((manager.client.evaluate as sinon.SinonStub).calledWith(1, 2, 'y')).to.be.true; }); + + it('should error when server context is missing', async () => { + const tool = createDebugEvaluateTool(loadServices, undefined); + const result = await tool.handler({session_id: 'x', thread_id: 1, expression: 'y'}); + expect(result.isError).to.be.true; + }); + }); + + describe('debug_get_variables', () => { + it('should return variables with has_children flag based on type', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugGetVariablesTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, thread_id: 1}); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{ + variables: Array<{name: string; type: string; has_children: boolean; scope?: string}>; + }>(result); + expect(json.variables).to.have.lengthOf(3); + const x = json.variables.find((v) => v.name === 'x')!; + expect(x.has_children).to.be.false; + const obj = json.variables.find((v) => v.name === 'obj')!; + expect(obj.has_children).to.be.true; + }); + + it('should filter by scope', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugGetVariablesTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, thread_id: 1, scope: 'global'}); + + const json = getResultJson<{variables: Array<{name: string}>}>(result); + expect(json.variables).to.have.lengthOf(1); + expect(json.variables[0].name).to.equal('g'); + }); + + it('should use getMembers when object_path is provided', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugGetVariablesTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, thread_id: 1, object_path: 'obj'}); + + expect((manager.client.getMembers as sinon.SinonStub).calledOnce).to.be.true; + const json = getResultJson<{variables: Array<{name: string}>}>(result); + expect(json.variables).to.have.lengthOf(1); + expect(json.variables[0].name).to.equal('foo'); + }); + + it('should truncate long values', async () => { + const longValue = 'x'.repeat(300); + const manager = createMockManager({ + client: { + getVariables: sinon.stub().resolves({ + object_members: [{name: 'big', type: 'string', value: longValue, scope: 'local'}], + count: 1, + start: 0, + total: 1, + _v: '2.0', + }), + getMembers: sinon.stub(), + evaluate: sinon.stub(), + getThread: sinon.stub(), + deleteBreakpoints: sinon.stub(), + }, + }); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugGetVariablesTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, thread_id: 1}); + + const json = getResultJson<{variables: Array<{value: string}>}>(result); + expect(json.variables[0].value).to.have.lengthOf(203); // 200 + '...' + expect(json.variables[0].value.endsWith('...')).to.be.true; + }); + + it('should error when server context is missing', async () => { + const tool = createDebugGetVariablesTool(loadServices, undefined); + const result = await tool.handler({session_id: 'x', thread_id: 1}); + expect(result.isError).to.be.true; + }); + }); + + describe('debug_set_breakpoints', () => { + it('should set breakpoints and map paths', async () => { + const manager = createMockManager({ + setBreakpoints: sinon + .stub() + .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), + }); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugSetBreakpointsTool(loadServices, serverContext); + const result = await tool.handler({ + session_id: entry.sessionId, + breakpoints: [{file: '/app_test/cartridge/controllers/Cart.js', line: 42}], + }); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{breakpoints: Array<{id: number; verified: boolean; file: string}>}>(result); + expect(json.breakpoints).to.have.lengthOf(1); + expect(json.breakpoints[0].verified).to.be.true; + }); + + it('should warn when path cannot be round-trip mapped', async () => { + const manager = createMockManager({ + setBreakpoints: sinon.stub().resolves([{id: 1, line_number: 10, script_path: '/unknown/cartridge/foo.js'}]), + }); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugSetBreakpointsTool(loadServices, serverContext); + const result = await tool.handler({ + session_id: entry.sessionId, + breakpoints: [{file: '/unknown/cartridge/foo.js', line: 10}], + }); + + const json = getResultJson<{breakpoints: Array<{verified: boolean}>; warnings?: string[]}>(result); + expect(json.breakpoints[0].verified).to.be.false; + expect(json.warnings).to.exist; + expect(json.warnings![0]).to.include('could not be mapped back to a local file'); + }); + + it('should support breakpoint conditions', async () => { + const setBreakpointsStub = sinon + .stub() + .resolves([ + {id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js', condition: 'x > 5'}, + ]); + const manager = createMockManager({setBreakpoints: setBreakpointsStub}); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugSetBreakpointsTool(loadServices, serverContext); + await tool.handler({ + session_id: entry.sessionId, + breakpoints: [{file: '/app_test/cartridge/controllers/Cart.js', line: 42, condition: 'x > 5'}], + }); + + const [bpInputs] = setBreakpointsStub.firstCall.args; + expect(bpInputs[0].condition).to.equal('x > 5'); + }); + + it('should error when server context is missing', async () => { + const tool = createDebugSetBreakpointsTool(loadServices, undefined); + const result = await tool.handler({session_id: 'x', breakpoints: [{file: '/a/b.js', line: 1}]}); + expect(result.isError).to.be.true; + }); + }); + + describe('debug_step_* tools', () => { + it('should create three step tools', () => { + const tools = createDebugStepTools(loadServices, serverContext); + expect(tools).to.have.lengthOf(3); + expect(tools.map((t) => t.name)).to.deep.equal(['debug_step_over', 'debug_step_into', 'debug_step_out']); + }); + + it('step_over should call manager.stepOver', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const [stepOver] = createDebugStepTools(loadServices, serverContext); + + const result = await stepOver.handler({session_id: entry.sessionId, thread_id: 3}); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{action: string}>(result); + expect(json.action).to.equal('step_over'); + expect((manager.stepOver as sinon.SinonStub).calledWith(3)).to.be.true; + }); + + it('step_into should call manager.stepInto', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const [, stepInto] = createDebugStepTools(loadServices, serverContext); + + await stepInto.handler({session_id: entry.sessionId, thread_id: 3}); + + expect((manager.stepInto as sinon.SinonStub).calledWith(3)).to.be.true; + }); + + it('step_out should call manager.stepOut', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const stepOut = createDebugStepTools(loadServices, serverContext)[2]; + + await stepOut.handler({session_id: entry.sessionId, thread_id: 3}); + + expect((manager.stepOut as sinon.SinonStub).calledWith(3)).to.be.true; + }); + + it('should error when server context is missing', async () => { + const [stepOver] = createDebugStepTools(loadServices, undefined); + const result = await stepOver.handler({session_id: 'x', thread_id: 1}); + expect(result.isError).to.be.true; + }); + }); + + describe('debug_wait_for_stop', () => { + it('should return immediately if a thread is already halted', async () => { + const haltedThread = { + id: 5, + status: 'halted', + call_stack: [ + { + index: 0, + location: {function_name: 'show', line_number: 10, script_path: '/app_test/cartridge/controllers/Cart.js'}, + }, + ], + }; + const manager = createMockManager({getKnownThreads: sinon.stub().returns([haltedThread])}); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugWaitForStopTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId}); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{halted: boolean; thread_id: number; location: {line: number}}>(result); + expect(json.halted).to.be.true; + expect(json.thread_id).to.equal(5); + expect(json.location.line).to.equal(10); + }); + + it('should handle halted thread with no call stack', async () => { + const manager = createMockManager({ + getKnownThreads: sinon.stub().returns([{id: 5, status: 'halted', call_stack: []}]), + }); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugWaitForStopTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId}); + + const json = getResultJson<{halted: boolean; location?: unknown}>(result); + expect(json.halted).to.be.true; + expect(json.location).to.be.undefined; + }); + + it('should time out when no halt occurs', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugWaitForStopTool(loadServices, serverContext); + const result = await tool.handler({session_id: entry.sessionId, timeout_ms: 50}); + + const json = getResultJson<{halted: boolean; timed_out?: boolean}>(result); + expect(json.halted).to.be.false; + expect(json.timed_out).to.be.true; + }); + + it('should resolve when onThreadStopped callback fires', async () => { + const manager = createMockManager(); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugWaitForStopTool(loadServices, serverContext); + const promise = tool.handler({session_id: entry.sessionId, timeout_ms: 5000}); + + // Simulate thread halt via the waiter mechanism + setTimeout(() => { + const waiter = entry.haltWaiters.shift()!; + clearTimeout(waiter.timer); + waiter.resolve({ + id: 7, + status: 'halted', + call_stack: [ + { + index: 0, + location: {function_name: 'foo', line_number: 5, script_path: '/app_test/cartridge/foo.js'}, + }, + ], + }); + }, 10); + + const result = await promise; + const json = getResultJson<{halted: boolean; thread_id: number}>(result); + expect(json.halted).to.be.true; + expect(json.thread_id).to.equal(7); + }); + + it('should error when server context is missing', async () => { + const tool = createDebugWaitForStopTool(loadServices, undefined); + const result = await tool.handler({session_id: 'x'}); + expect(result.isError).to.be.true; + }); + }); + + describe('debug_capture_at_breakpoint', () => { + it('should set breakpoint, wait, capture, and optionally continue', async () => { + const haltedThread = { + id: 5, + status: 'halted', + call_stack: [ + { + index: 0, + location: {function_name: 'show', line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}, + }, + ], + }; + const manager = createMockManager({ + getKnownThreads: sinon.stub().returns([haltedThread]), + setBreakpoints: sinon + .stub() + .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), + }); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); + const result = await tool.handler({ + session_id: entry.sessionId, + file: '/app_test/cartridge/controllers/Cart.js', + line: 42, + expressions: ['x', 'y'], + auto_continue: true, + }); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{ + halted: boolean; + thread_id: number; + stack: unknown[]; + variables: unknown[]; + evaluations: Array<{expression: string}>; + auto_continued: boolean; + }>(result); + expect(json.halted).to.be.true; + expect(json.thread_id).to.equal(5); + expect(json.stack).to.have.lengthOf(1); + expect(json.variables).to.have.lengthOf(3); + expect(json.evaluations).to.have.lengthOf(2); + expect(json.auto_continued).to.be.true; + expect((manager.resume as sinon.SinonStub).calledOnce).to.be.true; + }); + + it('should handle evaluation errors gracefully', async () => { + const haltedThread = { + id: 5, + status: 'halted', + call_stack: [ + { + index: 0, + location: {function_name: 'f', line_number: 1, script_path: '/app_test/cartridge/x.js'}, + }, + ], + }; + const manager = createMockManager({ + getKnownThreads: sinon.stub().returns([haltedThread]), + setBreakpoints: sinon + .stub() + .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), + }); + (manager.client.evaluate as sinon.SinonStub).rejects(new Error('bad expression')); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); + const result = await tool.handler({ + session_id: entry.sessionId, + file: '/app_test/cartridge/controllers/Cart.js', + line: 42, + expressions: ['broken'], + }); + + const json = getResultJson<{evaluations: Array<{result: string}>}>(result); + expect(json.evaluations[0].result).to.include('Error: bad expression'); + }); + + it('should time out when no halt occurs', async () => { + const manager = createMockManager({ + setBreakpoints: sinon + .stub() + .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), + }); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); + const result = await tool.handler({ + session_id: entry.sessionId, + file: '/app_test/cartridge/controllers/Cart.js', + line: 42, + timeout_ms: 50, + }); + + const json = getResultJson<{halted: boolean; timed_out?: boolean}>(result); + expect(json.halted).to.be.false; + expect(json.timed_out).to.be.true; + }); + + it('should fire trigger_url in background', async () => { + const haltedThread = { + id: 5, + status: 'halted', + call_stack: [ + { + index: 0, + location: {function_name: 'f', line_number: 1, script_path: '/app_test/cartridge/x.js'}, + }, + ], + }; + const manager = createMockManager({ + getKnownThreads: sinon.stub().returns([haltedThread]), + setBreakpoints: sinon + .stub() + .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), + }); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const fetchStub = sinon.stub(globalThis, 'fetch').resolves(new Response('', {status: 200})); + + try { + const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); + const result = await tool.handler({ + session_id: entry.sessionId, + file: '/app_test/cartridge/controllers/Cart.js', + line: 42, + trigger_url: 'https://example.com/trigger', + }); + + const json = getResultJson<{trigger_status?: number}>(result); + expect(json.trigger_status).to.equal(200); + expect(fetchStub.calledWith('https://example.com/trigger')).to.be.true; + } finally { + fetchStub.restore(); + } + }); + + it('should handle trigger_url fetch failure', async () => { + const haltedThread = { + id: 5, + status: 'halted', + call_stack: [ + { + index: 0, + location: {function_name: 'f', line_number: 1, script_path: '/app_test/cartridge/x.js'}, + }, + ], + }; + const manager = createMockManager({ + getKnownThreads: sinon.stub().returns([haltedThread]), + setBreakpoints: sinon + .stub() + .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), + }); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const fetchStub = sinon.stub(globalThis, 'fetch').rejects(new Error('network')); + + try { + const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); + const result = await tool.handler({ + session_id: entry.sessionId, + file: '/app_test/cartridge/controllers/Cart.js', + line: 42, + trigger_url: 'https://example.com/trigger', + }); + + const json = getResultJson<{trigger_status?: number}>(result); + expect(json.trigger_status).to.be.undefined; + } finally { + fetchStub.restore(); + } + }); + + it('should wait via waiter when no thread is already halted', async () => { + const manager = createMockManager({ + setBreakpoints: sinon + .stub() + .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), + }); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); + const promise = tool.handler({ + session_id: entry.sessionId, + file: '/app_test/cartridge/controllers/Cart.js', + line: 42, + timeout_ms: 5000, + }); + + setTimeout(() => { + const waiter = entry.haltWaiters.shift()!; + clearTimeout(waiter.timer); + waiter.resolve({ + id: 9, + status: 'halted', + call_stack: [ + { + index: 0, + location: {function_name: 'foo', line_number: 5, script_path: '/app_test/cartridge/foo.js'}, + }, + ], + }); + }, 10); + + const result = await promise; + const json = getResultJson<{halted: boolean; thread_id: number}>(result); + expect(json.halted).to.be.true; + expect(json.thread_id).to.equal(9); + }); + + it('should truncate long variable values in capture', async () => { + const longValue = 'y'.repeat(300); + const haltedThread = { + id: 5, + status: 'halted', + call_stack: [ + { + index: 0, + location: {function_name: 'f', line_number: 1, script_path: '/app_test/cartridge/x.js'}, + }, + ], + }; + const manager = createMockManager({ + getKnownThreads: sinon.stub().returns([haltedThread]), + setBreakpoints: sinon + .stub() + .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), + }); + (manager.client.getVariables as sinon.SinonStub).resolves({ + object_members: [{name: 'big', type: 'string', value: longValue, scope: 'local'}], + count: 1, + start: 0, + total: 1, + _v: '2.0', + }); + const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); + const result = await tool.handler({ + session_id: entry.sessionId, + file: '/app_test/cartridge/controllers/Cart.js', + line: 42, + }); + + const json = getResultJson<{variables: Array<{value: string}>}>(result); + expect(json.variables[0].value.endsWith('...')).to.be.true; + }); + + it('should error when server context is missing', async () => { + const tool = createDebugCaptureAtBreakpointTool(loadServices, undefined); + const result = await tool.handler({session_id: 'x', file: '/a.js', line: 1}); + expect(result.isError).to.be.true; + }); + }); + + describe('debug_start_session', () => { + let tmpDir: string; + let connectStub: sinon.SinonStub; + + beforeEach(() => { + // Create a real cartridge fixture so findCartridges works + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'debug-start-')); + const cartridgeDir = path.join(tmpDir, 'app_test'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); + + // Stub the manager's network calls + connectStub = sinon.stub(DebugSessionManager.prototype, 'connect').resolves(); + sinon.stub(DebugSessionManager.prototype, 'disconnect').resolves(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, {recursive: true, force: true}); + sinon.restore(); + }); + + it('should start a session and return session_id, hostname, cartridges, and mappings', async () => { + const tool = createDebugStartSessionTool(loadServices, serverContext); + const result = await tool.handler({cartridge_directory: tmpDir}); + + expect(result.isError).to.be.undefined; + const json = getResultJson<{ + session_id: string; + hostname: string; + cartridges: string[]; + cartridge_mappings: Record; + warnings: string[]; + }>(result); + + expect(json.session_id).to.be.a('string'); + expect(json.hostname).to.equal('test.example.com'); + expect(json.cartridges).to.deep.equal(['app_test']); + expect(json.cartridge_mappings).to.have.property('app_test'); + expect(connectStub.calledOnce).to.be.true; + }); + + it('should warn when no cartridges found', async () => { + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-')); + try { + const tool = createDebugStartSessionTool(loadServices, serverContext); + const result = await tool.handler({cartridge_directory: emptyDir}); + + const json = getResultJson<{warnings: string[]}>(result); + expect(json.warnings).to.not.be.empty; + } finally { + fs.rmSync(emptyDir, {recursive: true, force: true}); + } + }); + + it('should error when credentials are missing', async () => { + const servicesNoAuth = new Services({ + resolvedConfig: createMockResolvedConfig({}), + }); + const tool = createDebugStartSessionTool(() => servicesNoAuth, serverContext); + const result = await tool.handler({cartridge_directory: tmpDir}); + + expect(result.isError).to.be.true; + expect(getResultText(result)).to.include('Basic auth credentials'); + }); + + it('should error when server context is missing', async () => { + const tool = createDebugStartSessionTool(loadServices, undefined); + const result = await tool.handler({cartridge_directory: tmpDir}); + expect(result.isError).to.be.true; + }); + + it('should use custom client_id', async () => { + const tool = createDebugStartSessionTool(loadServices, serverContext); + await tool.handler({cartridge_directory: tmpDir, client_id: 'custom-client'}); + + const sessions = serverContext.debugSessions.listSessions(); + expect(sessions[0].clientId).to.equal('custom-client'); + }); + + it('should resolve halt waiters via onThreadStopped callback', async () => { + const tool = createDebugStartSessionTool(loadServices, serverContext); + await tool.handler({cartridge_directory: tmpDir}); + + const entry = serverContext.debugSessions.listSessions()[0]; + // Register a halt waiter + const halted = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timeout')), 1000); + entry.haltWaiters.push({ + resolve(t) { + clearTimeout(timer); + resolve(t.id); + }, + reject, + timer, + }); + }); + + // Fire the callback registered by start_session + const callbacks = (entry.manager as unknown as {callbacks: {onThreadStopped: (t: unknown) => void}}).callbacks; + callbacks.onThreadStopped({ + id: 42, + status: 'halted', + call_stack: [], + }); + + const id = await halted; + expect(id).to.equal(42); + }); }); }); diff --git a/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts b/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts index baba998c2..ce52dd0b8 100644 --- a/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts +++ b/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts @@ -202,6 +202,39 @@ describe('DebugSessionRegistry', () => { it('should be a no-op for unknown session ID', async () => { await registry.destroySession('nonexistent'); // Should not throw }); + + it('should swallow disconnect errors (best-effort cleanup)', async () => { + const manager = createMockManager({disconnect: sinon.stub().rejects(new Error('boom'))}); + const entry = registry.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + await registry.destroySession(entry.sessionId); // Should not throw + expect(registry.getSession(entry.sessionId)).to.be.undefined; + }); + }); + + describe('cleanupIdleSessions', () => { + it('should destroy sessions idle past TTL', async () => { + const manager = createMockManager(); + const entry = registry.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + // Fake an ancient lastActivityAt + entry.lastActivityAt = Date.now() - 31 * 60 * 1000; + + // Invoke the private method via any-cast for coverage + await (registry as unknown as {cleanupIdleSessions: () => Promise}).cleanupIdleSessions(); + + expect(registry.getSession(entry.sessionId)).to.be.undefined; + expect((manager.disconnect as sinon.SinonStub).calledOnce).to.be.true; + }); + + it('should leave active sessions alone', async () => { + const manager = createMockManager(); + const entry = registry.registerSession('host', 'c', manager, createMockSourceMapper(), []); + + await (registry as unknown as {cleanupIdleSessions: () => Promise}).cleanupIdleSessions(); + + expect(registry.getSession(entry.sessionId)).to.equal(entry); + }); }); describe('destroyAll', () => { From e78679be95c0c66beb912078ab0bfdb2df81edd5 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Wed, 6 May 2026 17:35:12 -0400 Subject: [PATCH 13/14] Refactor debug MCP tools: shared projections, registry helpers, doc updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 cleanup based on PR review feedback (patricksullivansf, yhsieh1) and self-review. DRY: - Extract shared projection helpers to SDK (projections.ts): truncateValue, isPrimitiveType, projectFrame, projectVariable, projectBreakpoint, projectThreadLocation, plus exported types MappedFrame/MappedVariable/MappedBreakpoint/MappedLocation. Used by both MCP debug tools and CLI RPC mode — eliminates 5+ copies of the same projection code and the MAX_VALUE_LENGTH/PRIMITIVE_TYPES constants. - Add getSessionEntry/getRegistry helpers to session-registry. Replace the 4-line "registry not available" + getSessionOrThrow boilerplate in 11 tools. - Move halt-waiter promise pattern into DebugSessionRegistry.waitForHalt. Used by debug_wait_for_stop and debug_capture_at_breakpoint instead of duplicating the promise/timer/cleanup logic. - Replace step-action switch with a lookup map. API: - DebugSessionRegistry.registerSession now takes an options object (yhsieh1 nit) instead of 5 positional parameters. Docs: - Add "Recovery from broken or orphaned sessions" section to docs/mcp/tools/diagnostics.md (patricksullivansf concern). - Link to main authentication and configuration guides. - Add JSDoc to ServerContext explaining stdio-per-client isolation vs shared transport caveats (patricksullivansf concern). Tool descriptions (yhsieh1 audit): - Drop user-facing prose, sharpen for agent consumption. - debug_start_session: explicitly mention SFRA controllers, custom API scripts, hooks, jobs as use cases. - debug_end_session: IMPORTANT call-out to always end sessions. Coverage stays at 99.81% statements/lines, 94.81% branches, 98.93% functions — all above thresholds. --- docs/mcp/tools/diagnostics.md | 16 +- packages/b2c-cli/src/utils/debug/rpc.ts | 126 +++------ packages/b2c-dx-mcp/src/server-context.ts | 21 ++ .../debug-capture-at-breakpoint.ts | 103 ++------ .../src/tools/diagnostics/debug-continue.ts | 17 +- .../tools/diagnostics/debug-end-session.ts | 20 +- .../src/tools/diagnostics/debug-evaluate.ts | 22 +- .../src/tools/diagnostics/debug-get-stack.ts | 30 +-- .../tools/diagnostics/debug-get-variables.ts | 59 +---- .../tools/diagnostics/debug-list-sessions.ts | 27 +- .../diagnostics/debug-set-breakpoints.ts | 41 +-- .../tools/diagnostics/debug-start-session.ts | 24 +- .../src/tools/diagnostics/debug-step.ts | 38 +-- .../tools/diagnostics/debug-wait-for-stop.ts | 71 +----- .../src/tools/diagnostics/session-registry.ts | 61 ++++- .../tools/diagnostics/debug-tools.test.ts | 240 +++++++++++++++--- .../diagnostics/session-registry.test.ts | 170 ++++++++++--- .../src/operations/debug/index.ts | 10 + .../src/operations/debug/projections.ts | 137 ++++++++++ 19 files changed, 730 insertions(+), 503 deletions(-) create mode 100644 packages/b2c-tooling-sdk/src/operations/debug/projections.ts diff --git a/docs/mcp/tools/diagnostics.md b/docs/mcp/tools/diagnostics.md index e6c126dc7..b244cc63b 100644 --- a/docs/mcp/tools/diagnostics.md +++ b/docs/mcp/tools/diagnostics.md @@ -4,13 +4,25 @@ description: MCP tools for script debugging on B2C Commerce instances. # Diagnostics Tools -MCP tools for connecting to the B2C Commerce Script Debugger API (SDAPI), setting breakpoints, inspecting variables, and stepping through server-side code. These tools are available in the **CARTRIDGES**, **SCAPI**, and **STOREFRONTNEXT** toolsets. +MCP tools for connecting to the B2C Commerce Script Debugger API (SDAPI), setting breakpoints, inspecting variables, and stepping through server-side code. These tools are available in the **CARTRIDGES** and **SCAPI** toolsets. ## Authentication All debug tools require **Basic Auth** credentials (username and password) for a Business Manager user with the `WebDAV_Manage_Customization` permission. -The script debugger must be enabled on the instance: Business Manager > Administration > Development Configuration > Script Debugger > Enable. +The script debugger must also be enabled on the instance: Business Manager > Administration > Development Configuration > Script Debugger > Enable. + +See the [Authentication guide](../../guide/authentication) and [Configuration guide](../../guide/configuration) for credential setup details. + +## Recovery from broken or orphaned sessions + +Debug sessions are stateful and live in the MCP server process. If the agent loses track of an active session (context flush, crash, restart), or breakpoints stop firing as expected: + +1. **List active sessions** — call `debug_list_sessions` (no args). It returns all sessions known to the server with their `session_id`, `hostname`, halted threads, and currently armed breakpoints. +2. **End orphaned sessions** — call `debug_end_session` with the `session_id` to free the debugger slot on the instance. +3. **SDAPI single-client guarantee** — the script debugger only supports one client per `client_id` per host. Calling `debug_start_session` with the same `client_id` against the same host implicitly takes over (replaces) any prior client. This is the safety net when a session is lost without a clean shutdown. +4. **Idle cleanup** — sessions inactive for 30 minutes are automatically cleaned up by the server. +5. **Restart the MCP server** — as a last resort, restarting the MCP server destroys all session state. The orphaned debugger slot on the instance will be freed by SDAPI's own 60-second halt-timeout or by the next `debug_start_session` with the same client ID. --- diff --git a/packages/b2c-cli/src/utils/debug/rpc.ts b/packages/b2c-cli/src/utils/debug/rpc.ts index 339ad84c8..02eafbc36 100644 --- a/packages/b2c-cli/src/utils/debug/rpc.ts +++ b/packages/b2c-cli/src/utils/debug/rpc.ts @@ -12,9 +12,13 @@ import type { SourceMapper, } from '@salesforce/b2c-tooling-sdk/operations/debug'; import type {CartridgeMapping} from '@salesforce/b2c-tooling-sdk/operations/code'; -import {resolveBreakpointPath} from '@salesforce/b2c-tooling-sdk/operations/debug'; - -const MAX_VALUE_LENGTH = 200; +import { + projectBreakpoint, + projectFrame, + projectThreadLocation, + projectVariable, + resolveBreakpointPath, +} from '@salesforce/b2c-tooling-sdk/operations/debug'; interface RpcRequest { id?: number | string; @@ -63,18 +67,9 @@ export class DebugRpc { this.currentThreadId = thread.id; this.currentFrameIndex = 0; - const topFrame = thread.call_stack?.[0]; - const loc = topFrame?.location; this.emitEvent('thread_stopped', { thread_id: thread.id, - location: loc - ? { - file: this.sourceMapper.toLocalPath(loc.script_path) ?? null, - line: loc.line_number, - function_name: loc.function_name, - script_path: loc.script_path, - } - : null, + location: projectThreadLocation(thread, this.sourceMapper), }); } @@ -113,13 +108,13 @@ export class DebugRpc { private async handleCommand(command: string, args: Record): Promise { switch (command) { case 'continue': { - const threadId = (args.thread_id as number) ?? this.getThread(); + const threadId = this.resolveThreadId(args); await this.manager.resume(threadId); return {thread_id: threadId, status: 'resumed'}; } case 'evaluate': { - const threadId = (args.thread_id as number) ?? this.getThread(); + const threadId = this.resolveThreadId(args); const frameIndex = (args.frame_index as number) ?? this.currentFrameIndex; const expression = args.expression as string; if (!expression) throw new Error('Missing required arg: expression'); @@ -128,86 +123,45 @@ export class DebugRpc { } case 'get_stack': { - const threadId = (args.thread_id as number) ?? this.getThread(); + const threadId = this.resolveThreadId(args); const thread = await this.manager.client.getThread(threadId); return { thread_id: thread.id, - frames: thread.call_stack.map((frame) => ({ - index: frame.index, - function_name: frame.location.function_name, - file: this.sourceMapper.toLocalPath(frame.location.script_path) ?? null, - line: frame.location.line_number, - script_path: frame.location.script_path, - })), + frames: thread.call_stack.map((frame) => projectFrame(frame, this.sourceMapper)), }; } case 'get_variables': { - const threadId = (args.thread_id as number) ?? this.getThread(); + const threadId = this.resolveThreadId(args); const frameIndex = (args.frame_index as number) ?? this.currentFrameIndex; const objectPath = args.object_path as string | undefined; if (objectPath) { const result = await this.manager.client.getMembers(threadId, frameIndex, objectPath); - return { - variables: result.object_members.map((m) => ({ - name: m.name, - type: m.type, - value: truncateValue(m.value), - has_children: !isPrimitive(m.type), - })), - }; + return {variables: result.object_members.map((m) => projectVariable(m, {includeScope: false}))}; } const result = await this.manager.client.getVariables(threadId, frameIndex); - let members = result.object_members; - if (args.scope) { - members = members.filter((m) => m.scope === args.scope); - } - return { - variables: members.map((m) => ({ - name: m.name, - type: m.type, - value: truncateValue(m.value), - scope: m.scope, - has_children: !isPrimitive(m.type), - })), - }; + const members = args.scope + ? result.object_members.filter((m) => m.scope === args.scope) + : result.object_members; + return {variables: members.map((m) => projectVariable(m))}; } case 'list_breakpoints': { const bps = await this.manager.client.getBreakpoints(); - return { - breakpoints: bps.map((bp) => ({ - id: bp.id, - file: this.sourceMapper.toLocalPath(bp.script_path) ?? null, - line: bp.line_number, - script_path: bp.script_path, - condition: bp.condition, - })), - }; + return {breakpoints: bps.map((bp) => projectBreakpoint(bp, this.sourceMapper))}; } case 'list_threads': { const threads = this.manager.getKnownThreads(); return { - threads: threads.map((t) => { - const topFrame = t.call_stack?.[0]; - const loc = topFrame?.location; - return { - thread_id: t.id, - status: t.status, - current: t.id === this.currentThreadId, - location: loc - ? { - file: this.sourceMapper.toLocalPath(loc.script_path) ?? null, - line: loc.line_number, - function_name: loc.function_name, - script_path: loc.script_path, - } - : null, - }; - }), + threads: threads.map((t) => ({ + thread_id: t.id, + status: t.status, + current: t.id === this.currentThreadId, + location: projectThreadLocation(t, this.sourceMapper), + })), }; } @@ -237,31 +191,23 @@ export class DebugRpc { })); const result = await this.manager.setBreakpoints(inputs); - return { - breakpoints: result.map((bp) => ({ - id: bp.id, - file: this.sourceMapper.toLocalPath(bp.script_path) ?? null, - line: bp.line_number, - script_path: bp.script_path, - condition: bp.condition, - })), - }; + return {breakpoints: result.map((bp) => projectBreakpoint(bp, this.sourceMapper))}; } case 'step_into': { - const threadId = (args.thread_id as number) ?? this.getThread(); + const threadId = this.resolveThreadId(args); await this.manager.stepInto(threadId); return {thread_id: threadId, action: 'step_into'}; } case 'step_out': { - const threadId = (args.thread_id as number) ?? this.getThread(); + const threadId = this.resolveThreadId(args); await this.manager.stepOut(threadId); return {thread_id: threadId, action: 'step_out'}; } case 'step_over': { - const threadId = (args.thread_id as number) ?? this.getThread(); + const threadId = this.resolveThreadId(args); await this.manager.stepOver(threadId); return {thread_id: threadId, action: 'step_over'}; } @@ -295,16 +241,12 @@ export class DebugRpc { } } + /** Resolve a thread_id arg, falling back to the currently selected thread. */ + private resolveThreadId(args: Record): number { + return (args.thread_id as number) ?? this.getThread(); + } + private sendResponse(response: RpcResponse): void { this.output.write(JSON.stringify(response) + '\n'); } } - -function isPrimitive(type: string): boolean { - return ['boolean', 'Boolean', 'null', 'number', 'Number', 'string', 'String', 'undefined'].includes(type); -} - -function truncateValue(value: string): string { - if (value.length <= MAX_VALUE_LENGTH) return value; - return value.slice(0, MAX_VALUE_LENGTH) + '...'; -} diff --git a/packages/b2c-dx-mcp/src/server-context.ts b/packages/b2c-dx-mcp/src/server-context.ts index ef882bdd1..f62e8ce33 100644 --- a/packages/b2c-dx-mcp/src/server-context.ts +++ b/packages/b2c-dx-mcp/src/server-context.ts @@ -6,6 +6,27 @@ import {DebugSessionRegistry} from './tools/diagnostics/session-registry.js'; +/** + * Server-scoped persistent state that lives for the lifetime of the MCP + * server process. Holds registries for stateful resources (debug sessions, + * future log watches) that need to span multiple tool invocations. + * + * ## Multi-agent / shared-state caveats + * + * One `ServerContext` is created per MCP server process. With the default + * stdio transport, each MCP client connection spawns its own server + * subprocess, so this state is naturally isolated per client/agent. + * + * If this server is ever wired up to a shared transport (e.g. HTTP with + * multiple connected clients), `ServerContext` state would be shared + * across all connected clients. Sub-agents that run within the same MCP + * client would also share the same context. Tools that mutate registries + * (like the debug tools) should not assume single-tenant access. + * + * The debug registry already handles concurrent sessions via session IDs + * and host:client_id pairs, so multi-agent use is functional but agents + * may see each other's sessions via `debug_list_sessions`. + */ export class ServerContext { readonly debugSessions: DebugSessionRegistry; diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts index 775a76a82..9acf6fe59 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-capture-at-breakpoint.ts @@ -10,15 +10,17 @@ import type {Services} from '../../services.js'; import type {ServerContext} from '../../server-context.js'; import {createToolAdapter, jsonResult} from '../adapter.js'; import { + projectFrame, + projectVariable, resolveBreakpointPath, type BreakpointInput, - type SdapiScriptThread, + type MappedFrame, + type MappedVariable, } from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {getRegistry, getSessionEntry} from './session-registry.js'; const DEFAULT_TIMEOUT_MS = 30_000; const MAX_TIMEOUT_MS = 120_000; -const MAX_VALUE_LENGTH = 200; -const PRIMITIVE_TYPES = new Set(['boolean', 'Boolean', 'null', 'number', 'Number', 'string', 'String', 'undefined']); interface CaptureInput { session_id: string; @@ -40,24 +42,9 @@ interface CaptureOutput { halted: boolean; timed_out?: boolean; thread_id?: number; - stack?: Array<{ - index: number; - function_name: string; - file: null | string; - line: number; - script_path: string; - }>; - variables?: Array<{ - name: string; - type: string; - value: string; - scope?: string; - has_children: boolean; - }>; - evaluations?: Array<{ - expression: string; - result: string; - }>; + stack?: MappedFrame[]; + variables?: MappedVariable[]; + evaluations?: Array<{expression: string; result: string}>; auto_continued: boolean; trigger_status?: number; } @@ -103,30 +90,22 @@ export function createDebugCaptureAtBreakpointTool( ), }, async execute(args, context) { - const registry = context.serverContext?.debugSessions; - if (!registry) { - throw new Error('Debug session registry not available'); - } - - const entry = registry.getSessionOrThrow(args.session_id); + const entry = getSessionEntry(context, args.session_id); + const registry = getRegistry(context); const timeout = Math.min(args.timeout_ms ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS); const scriptPath = resolveBreakpointPath(args.file, entry.sourceMapper, entry.cartridges); - const bpInput: BreakpointInput = { - script_path: scriptPath, - line_number: args.line, - condition: args.condition, - }; - - const existingBps = entry.breakpoints.map((bp) => ({ - script_path: bp.script_path, - line_number: bp.line_number, - condition: bp.condition, - })); - const allBps = [...existingBps, bpInput]; - const bpResult = await entry.manager.setBreakpoints(allBps); - entry.breakpoints = bpResult; + // Add breakpoint to existing set + const allBps: BreakpointInput[] = [ + ...entry.breakpoints.map((bp) => ({ + script_path: bp.script_path, + line_number: bp.line_number, + condition: bp.condition, + })), + {script_path: scriptPath, line_number: args.line, condition: args.condition}, + ]; + entry.breakpoints = await entry.manager.setBreakpoints(allBps); const breakpointInfo = { file: entry.sourceMapper.toLocalPath(scriptPath) ?? null, @@ -135,27 +114,13 @@ export function createDebugCaptureAtBreakpointTool( }; // Fire trigger URL in the background (it will hang when the breakpoint halts the thread) - let triggerPromise: Promise | undefined; - if (args.trigger_url) { - triggerPromise = fetch(args.trigger_url, {redirect: 'follow'}) - .then((r) => r.status) - .catch((): undefined => undefined); - } + const triggerPromise = args.trigger_url + ? fetch(args.trigger_url, {redirect: 'follow'}) + .then((r) => r.status) + .catch((): undefined => undefined) + : undefined; - // Wait for halt - const haltedThread = entry.manager.getKnownThreads().find((t) => t.status === 'halted'); - let thread: null | SdapiScriptThread = haltedThread ?? null; - - if (!thread) { - thread = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - const idx = entry.haltWaiters.findIndex((w) => w.timer === timer); - if (idx !== -1) entry.haltWaiters.splice(idx, 1); - resolve(null); - }, timeout); - entry.haltWaiters.push({resolve: (t) => resolve(t), reject, timer}); - }); - } + const thread = await registry.waitForHalt(entry, timeout); if (!thread) { return { @@ -167,22 +132,10 @@ export function createDebugCaptureAtBreakpointTool( } const threadDetail = await entry.manager.client.getThread(thread.id); - const stack = threadDetail.call_stack.map((frame) => ({ - index: frame.index, - function_name: frame.location.function_name, - file: entry.sourceMapper.toLocalPath(frame.location.script_path) ?? null, - line: frame.location.line_number, - script_path: frame.location.script_path, - })); + const stack = threadDetail.call_stack.map((frame) => projectFrame(frame, entry.sourceMapper)); const varsResult = await entry.manager.client.getVariables(thread.id, 0); - const variables = varsResult.object_members.map((m) => ({ - name: m.name, - type: m.type, - value: m.value.length > MAX_VALUE_LENGTH ? m.value.slice(0, MAX_VALUE_LENGTH) + '...' : m.value, - scope: m.scope, - has_children: !PRIMITIVE_TYPES.has(m.type), - })); + const variables = varsResult.object_members.map((m) => projectVariable(m)); const evaluations: Array<{expression: string; result: string}> = []; if (args.expressions) { diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-continue.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-continue.ts index 285f5ba93..a863452a4 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-continue.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-continue.ts @@ -9,6 +9,7 @@ import type {McpTool} from '../../utils/index.js'; import type {Services} from '../../services.js'; import type {ServerContext} from '../../server-context.js'; import {createToolAdapter, jsonResult} from '../adapter.js'; +import {getSessionEntry} from './session-registry.js'; interface ContinueInput { session_id: string; @@ -28,25 +29,17 @@ export function createDebugContinueTool( { name: 'debug_continue', description: - 'Resume execution of a halted thread. The thread continues until the next breakpoint or request completion.', + 'Resume execution of a halted thread. ' + + 'The thread runs until the next breakpoint or completes the current request.', toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), thread_id: z.number().int().describe('Thread ID of the halted thread to resume.'), }, async execute(args, context) { - const registry = context.serverContext?.debugSessions; - if (!registry) { - throw new Error('Debug session registry not available'); - } - - const entry = registry.getSessionOrThrow(args.session_id); + const entry = getSessionEntry(context, args.session_id); await entry.manager.resume(args.thread_id); - - return { - thread_id: args.thread_id, - status: 'resumed', - }; + return {thread_id: args.thread_id, status: 'resumed'}; }, formatOutput: (output) => jsonResult(output), }, diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-end-session.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-end-session.ts index 12b3fcf77..45a784855 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-end-session.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-end-session.ts @@ -9,6 +9,7 @@ import type {McpTool} from '../../utils/index.js'; import type {Services} from '../../services.js'; import type {ServerContext} from '../../server-context.js'; import {createToolAdapter, jsonResult} from '../adapter.js'; +import {getRegistry, getSessionEntry} from './session-registry.js'; interface EndSessionInput { session_id: string; @@ -28,9 +29,8 @@ export function createDebugEndSessionTool( { name: 'debug_end_session', description: - 'End a script debugger session. ' + - 'Disconnects from the SDAPI, stops polling, and cleans up resources. ' + - 'Optionally clears all breakpoints before disconnecting.', + 'End a script debugger session and free its slot on the instance. ' + + 'IMPORTANT: Always call this when finished debugging — leaving sessions open can interfere with other debuggers and consumes a debugger client slot.', toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), @@ -40,12 +40,7 @@ export function createDebugEndSessionTool( .describe('If true, delete all breakpoints before disconnecting. Defaults to false.'), }, async execute(args, context) { - const registry = context.serverContext?.debugSessions; - if (!registry) { - throw new Error('Debug session registry not available'); - } - - const entry = registry.getSessionOrThrow(args.session_id); + const entry = getSessionEntry(context, args.session_id); if (args.clear_breakpoints) { try { @@ -55,12 +50,9 @@ export function createDebugEndSessionTool( } } - await registry.destroySession(args.session_id); + await getRegistry(context).destroySession(args.session_id); - return { - session_id: args.session_id, - status: 'disconnected', - }; + return {session_id: args.session_id, status: 'disconnected'}; }, formatOutput: (output) => jsonResult(output), }, diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-evaluate.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-evaluate.ts index 03afbf148..e1f750e30 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-evaluate.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-evaluate.ts @@ -9,6 +9,7 @@ import type {McpTool} from '../../utils/index.js'; import type {Services} from '../../services.js'; import type {ServerContext} from '../../server-context.js'; import {createToolAdapter, jsonResult} from '../adapter.js'; +import {getSessionEntry} from './session-registry.js'; interface EvaluateInput { session_id: string; @@ -30,31 +31,20 @@ export function createDebugEvaluateTool( { name: 'debug_evaluate', description: - 'Evaluate an expression in the context of a halted thread and stack frame. ' + - 'WARNING: Expressions can have side effects (modify variables, call functions). ' + - 'Use with care on production-like instances.', + 'Evaluate a JavaScript expression in the context of a halted thread and stack frame. ' + + 'WARNING: Expressions may have side effects (modify variables, call functions). Use with care.', toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), - thread_id: z.number().int().describe('Thread ID from debug_wait_for_stop.'), + thread_id: z.number().int().describe('Thread ID from debug_wait_for_stop or debug_list_sessions.'), frame_index: z.number().int().min(0).optional().describe('Stack frame index (0 = top frame). Defaults to 0.'), expression: z.string().describe('JavaScript expression to evaluate in the frame context.'), }, async execute(args, context) { - const registry = context.serverContext?.debugSessions; - if (!registry) { - throw new Error('Debug session registry not available'); - } - - const entry = registry.getSessionOrThrow(args.session_id); + const entry = getSessionEntry(context, args.session_id); const frameIndex = args.frame_index ?? 0; - const result = await entry.manager.client.evaluate(args.thread_id, frameIndex, args.expression); - - return { - expression: result.expression, - result: result.result, - }; + return {expression: result.expression, result: result.result}; }, formatOutput: (output) => jsonResult(output), }, diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-stack.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-stack.ts index 6eaaee924..5a161b121 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-stack.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-stack.ts @@ -9,6 +9,8 @@ import type {McpTool} from '../../utils/index.js'; import type {Services} from '../../services.js'; import type {ServerContext} from '../../server-context.js'; import {createToolAdapter, jsonResult} from '../adapter.js'; +import {projectFrame, type MappedFrame} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {getSessionEntry} from './session-registry.js'; interface GetStackInput { session_id: string; @@ -17,13 +19,7 @@ interface GetStackInput { interface GetStackOutput { thread_id: number; - frames: Array<{ - index: number; - function_name: string; - file: null | string; - line: number; - script_path: string; - }>; + frames: MappedFrame[]; } export function createDebugGetStackTool( @@ -35,30 +31,18 @@ export function createDebugGetStackTool( name: 'debug_get_stack', description: 'Get the call stack for a halted thread. ' + - 'Returns stack frames with mapped local file paths and server script paths.', + 'Returns frames with mapped local file paths and server script paths.', toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), - thread_id: z.number().int().describe('Thread ID from debug_wait_for_stop.'), + thread_id: z.number().int().describe('Thread ID from debug_wait_for_stop or debug_list_sessions.'), }, async execute(args, context) { - const registry = context.serverContext?.debugSessions; - if (!registry) { - throw new Error('Debug session registry not available'); - } - - const entry = registry.getSessionOrThrow(args.session_id); + const entry = getSessionEntry(context, args.session_id); const thread = await entry.manager.client.getThread(args.thread_id); - return { thread_id: thread.id, - frames: thread.call_stack.map((frame) => ({ - index: frame.index, - function_name: frame.location.function_name, - file: entry.sourceMapper.toLocalPath(frame.location.script_path) ?? null, - line: frame.location.line_number, - script_path: frame.location.script_path, - })), + frames: thread.call_stack.map((frame) => projectFrame(frame, entry.sourceMapper)), }; }, formatOutput: (output) => jsonResult(output), diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-variables.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-variables.ts index f6a18a013..dbe905301 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-variables.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-get-variables.ts @@ -9,9 +9,8 @@ import type {McpTool} from '../../utils/index.js'; import type {Services} from '../../services.js'; import type {ServerContext} from '../../server-context.js'; import {createToolAdapter, jsonResult} from '../adapter.js'; - -const MAX_VALUE_LENGTH = 200; -const PRIMITIVE_TYPES = new Set(['boolean', 'Boolean', 'null', 'number', 'Number', 'string', 'String', 'undefined']); +import {projectVariable, type MappedVariable} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {getSessionEntry} from './session-registry.js'; interface GetVariablesInput { session_id: string; @@ -22,13 +21,7 @@ interface GetVariablesInput { } interface GetVariablesOutput { - variables: Array<{ - name: string; - type: string; - value: string; - scope?: string; - has_children: boolean; - }>; + variables: MappedVariable[]; } export function createDebugGetVariablesTool( @@ -40,13 +33,11 @@ export function createDebugGetVariablesTool( name: 'debug_get_variables', description: 'Get variables for a stack frame in a halted thread. ' + - 'By default returns top-frame local variables. ' + - 'Use scope to filter (local, closure, global). ' + - 'Use object_path to drill into nested objects.', + 'Defaults to top-frame locals. Use scope to filter (local/closure/global) or object_path to drill into nested objects.', toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), - thread_id: z.number().int().describe('Thread ID from debug_wait_for_stop.'), + thread_id: z.number().int().describe('Thread ID from debug_wait_for_stop or debug_list_sessions.'), frame_index: z.number().int().min(0).optional().describe('Stack frame index (0 = top frame). Defaults to 0.'), scope: z .enum(['local', 'closure', 'global']) @@ -60,42 +51,19 @@ export function createDebugGetVariablesTool( ), }, async execute(args, context) { - const registry = context.serverContext?.debugSessions; - if (!registry) { - throw new Error('Debug session registry not available'); - } - - const entry = registry.getSessionOrThrow(args.session_id); + const entry = getSessionEntry(context, args.session_id); const frameIndex = args.frame_index ?? 0; if (args.object_path) { const result = await entry.manager.client.getMembers(args.thread_id, frameIndex, args.object_path); - return { - variables: result.object_members.map((m) => ({ - name: m.name, - type: m.type, - value: truncateValue(m.value), - has_children: !PRIMITIVE_TYPES.has(m.type), - })), - }; + return {variables: result.object_members.map((m) => projectVariable(m, {includeScope: false}))}; } const result = await entry.manager.client.getVariables(args.thread_id, frameIndex); - let members = result.object_members; - - if (args.scope) { - members = members.filter((m) => m.scope === args.scope); - } - - return { - variables: members.map((m) => ({ - name: m.name, - type: m.type, - value: truncateValue(m.value), - scope: m.scope, - has_children: !PRIMITIVE_TYPES.has(m.type), - })), - }; + const members = args.scope + ? result.object_members.filter((m) => m.scope === args.scope) + : result.object_members; + return {variables: members.map((m) => projectVariable(m))}; }, formatOutput: (output) => jsonResult(output), }, @@ -103,8 +71,3 @@ export function createDebugGetVariablesTool( serverContext, ); } - -function truncateValue(value: string): string { - if (value.length <= MAX_VALUE_LENGTH) return value; - return value.slice(0, MAX_VALUE_LENGTH) + '...'; -} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts index f3705cadb..32ddfd3c1 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-list-sessions.ts @@ -8,6 +8,8 @@ import type {McpTool} from '../../utils/index.js'; import type {Services} from '../../services.js'; import type {ServerContext} from '../../server-context.js'; import {createToolAdapter, jsonResult} from '../adapter.js'; +import {projectBreakpoint, type MappedBreakpoint} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {getRegistry} from './session-registry.js'; interface ListSessionsOutput { sessions: Array<{ @@ -15,12 +17,7 @@ interface ListSessionsOutput { hostname: string; client_id: string; halted_threads: number[]; - breakpoints: Array<{ - id: number; - file: null | string; - line: number; - script_path: string; - }>; + breakpoints: MappedBreakpoint[]; created_at: string; last_activity_at: string; }>; @@ -34,17 +31,12 @@ export function createDebugListSessionsTool( { name: 'debug_list_sessions', description: - 'List all active script debugger sessions with their breakpoints and halted threads. ' + - 'Use this to check session state: whether breakpoints are armed, which threads are halted, and whether you need to call debug_get_variables or debug_continue. ' + - 'This is the recommended way to poll for halted threads in the non-blocking debug workflow.', + 'List active script debugger sessions with their breakpoints and any halted threads. ' + + 'Use this to discover orphaned sessions, check whether breakpoints are armed, and poll for halted threads in the non-blocking debug workflow.', toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: {}, async execute(_args, context) { - const registry = context.serverContext?.debugSessions; - if (!registry) { - throw new Error('Debug session registry not available'); - } - + const registry = getRegistry(context); const entries = registry.listSessions(); return { sessions: entries.map((entry) => ({ @@ -55,12 +47,7 @@ export function createDebugListSessionsTool( .getKnownThreads() .filter((t) => t.status === 'halted') .map((t) => t.id), - breakpoints: entry.breakpoints.map((bp) => ({ - id: bp.id, - file: entry.sourceMapper.toLocalPath(bp.script_path) ?? null, - line: bp.line_number, - script_path: bp.script_path, - })), + breakpoints: entry.breakpoints.map((bp) => projectBreakpoint(bp, entry.sourceMapper)), created_at: new Date(entry.createdAt).toISOString(), last_activity_at: new Date(entry.lastActivityAt).toISOString(), })), diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts index 91a1128ab..38bbe23c9 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-set-breakpoints.ts @@ -9,20 +9,21 @@ import type {McpTool} from '../../utils/index.js'; import type {Services} from '../../services.js'; import type {ServerContext} from '../../server-context.js'; import {createToolAdapter, jsonResult} from '../adapter.js'; -import {resolveBreakpointPath, type BreakpointInput} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import { + projectBreakpoint, + resolveBreakpointPath, + type BreakpointInput, + type MappedBreakpoint, +} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {getSessionEntry} from './session-registry.js'; interface SetBreakpointsInput { session_id: string; breakpoints: Array<{file: string; line: number; condition?: string}>; } -interface BreakpointResult { - id: number; - file: null | string; - line: number; - script_path: string; +interface BreakpointResult extends MappedBreakpoint { verified: boolean; - condition?: string; } interface SetBreakpointsOutput { @@ -39,8 +40,8 @@ export function createDebugSetBreakpointsTool( name: 'debug_set_breakpoints', description: 'Set breakpoints in a debug session. Replaces all previously set breakpoints. ' + - 'Accepts local file paths (mapped to server paths via cartridge discovery), cartridge-prefixed paths (e.g. app_storefront/cartridge/controllers/Cart.js), or server paths starting with /. ' + - 'Check the "verified" field and "warnings" in the response — if a path could not be mapped to a known cartridge, it will be flagged.', + 'Accepts local file paths (mapped to server paths via cartridge discovery), cartridge-prefixed paths, or server paths starting with /. ' + + 'Check the "verified" field and "warnings" — unmapped paths are flagged.', toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), @@ -62,28 +63,18 @@ export function createDebugSetBreakpointsTool( .describe('Array of breakpoints to set. Replaces all existing breakpoints.'), }, async execute(args, context) { - const registry = context.serverContext?.debugSessions; - if (!registry) { - throw new Error('Debug session registry not available'); - } - - const entry = registry.getSessionOrThrow(args.session_id); + const entry = getSessionEntry(context, args.session_id); const warnings: string[] = []; const bpInputs: BreakpointInput[] = args.breakpoints.map((bp) => { const scriptPath = resolveBreakpointPath(bp.file, entry.sourceMapper, entry.cartridges); - const roundTrip = entry.sourceMapper.toLocalPath(scriptPath); - if (!roundTrip) { + if (!entry.sourceMapper.toLocalPath(scriptPath)) { warnings.push( `"${bp.file}" resolved to server path "${scriptPath}" but could not be mapped back to a local file. ` + `Verify this path exists on the instance.`, ); } - return { - script_path: scriptPath, - line_number: bp.line, - condition: bp.condition, - }; + return {script_path: scriptPath, line_number: bp.line, condition: bp.condition}; }); const result = await entry.manager.setBreakpoints(bpInputs); @@ -91,12 +82,8 @@ export function createDebugSetBreakpointsTool( return { breakpoints: result.map((bp) => ({ - id: bp.id, - file: entry.sourceMapper.toLocalPath(bp.script_path) ?? null, - line: bp.line_number, - script_path: bp.script_path, + ...projectBreakpoint(bp, entry.sourceMapper), verified: entry.sourceMapper.toLocalPath(bp.script_path) !== undefined, - condition: bp.condition, })), warnings: warnings.length > 0 ? warnings : undefined, }; diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts index 3fd202c8d..11b17fe77 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-start-session.ts @@ -16,6 +16,7 @@ import { type SdapiScriptThread, } from '@salesforce/b2c-tooling-sdk/operations/debug'; import {findCartridges} from '@salesforce/b2c-tooling-sdk/operations/code'; +import {getRegistry} from './session-registry.js'; interface StartSessionInput { cartridge_directory?: string; @@ -38,11 +39,10 @@ export function createDebugStartSessionTool( { name: 'debug_start_session', description: - 'Start a new script debugger session on a B2C Commerce instance. ' + - 'Connects to the SDAPI, discovers cartridge mappings, and begins polling for halted threads. ' + - 'WARNING: Debug sessions can halt remote request threads on the instance. ' + - 'Use debug_end_session to cleanly disconnect when done. ' + - 'Requires Basic auth credentials (username/password).', + 'Start a script debugger session on a B2C Commerce instance to debug SFRA controllers, custom API scripts, hooks, jobs, or any server-side script. ' + + 'Returns a session_id for use with other debug tools, plus discovered cartridge mappings. ' + + 'WARNING: Debug sessions halt remote request threads on the instance. Always call debug_end_session when finished. ' + + 'Requires Basic auth credentials (username/password) and the script debugger enabled in Business Manager.', toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { cartridge_directory: z @@ -57,10 +57,7 @@ export function createDebugStartSessionTool( ), }, async execute(args, context) { - const registry = context.serverContext?.debugSessions; - if (!registry) { - throw new Error('Debug session registry not available'); - } + const registry = getRegistry(context); const credentials = context.services.getBasicAuthCredentials(); if (!credentials) { @@ -71,7 +68,6 @@ export function createDebugStartSessionTool( } const {hostname, username, password} = credentials; - const clientId = args.client_id ?? 'b2c-cli'; const cartridgeDir = context.services.resolveWithProjectDirectory(args.cartridge_directory); const cartridges = findCartridges(cartridgeDir); @@ -87,8 +83,6 @@ export function createDebugStartSessionTool( onThreadStopped(thread: SdapiScriptThread) { const entry = registry.findByHostAndClientId(hostname, clientId); if (!entry) return; - - // Resolve any pending halt waiters while (entry.haltWaiters.length > 0) { const waiter = entry.haltWaiters.shift()!; clearTimeout(waiter.timer); @@ -104,12 +98,10 @@ export function createDebugStartSessionTool( await manager.connect(); - const entry = registry.registerSession(hostname, clientId, manager, sourceMapper, cartridges); + const entry = registry.registerSession({hostname, clientId, manager, sourceMapper, cartridges}); const cartridgeMappings: Record = {}; - for (const c of cartridges) { - cartridgeMappings[c.name] = c.src; - } + for (const c of cartridges) cartridgeMappings[c.name] = c.src; return { session_id: entry.sessionId, diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-step.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-step.ts index aeed5f840..17f754030 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-step.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-step.ts @@ -8,7 +8,9 @@ import {z} from 'zod'; import type {McpTool} from '../../utils/index.js'; import type {Services} from '../../services.js'; import type {ServerContext} from '../../server-context.js'; +import type {DebugSessionManager} from '@salesforce/b2c-tooling-sdk/operations/debug'; import {createToolAdapter, jsonResult} from '../adapter.js'; +import {getSessionEntry} from './session-registry.js'; interface StepInput { session_id: string; @@ -22,6 +24,12 @@ interface StepOutput { type StepAction = 'step_into' | 'step_out' | 'step_over'; +const STEP_HANDLERS: Record Promise> = { + step_into: (m, id) => m.stepInto(id), + step_out: (m, id) => m.stepOut(id), + step_over: (m, id) => m.stepOver(id), +}; + function createStepTool( action: StepAction, description: string, @@ -38,33 +46,9 @@ function createStepTool( thread_id: z.number().int().describe('Thread ID of the halted thread to step.'), }, async execute(args, context) { - const registry = context.serverContext?.debugSessions; - if (!registry) { - throw new Error('Debug session registry not available'); - } - - const entry = registry.getSessionOrThrow(args.session_id); - const manager = entry.manager; - - switch (action) { - case 'step_into': { - await manager.stepInto(args.thread_id); - break; - } - case 'step_out': { - await manager.stepOut(args.thread_id); - break; - } - case 'step_over': { - await manager.stepOver(args.thread_id); - break; - } - } - - return { - thread_id: args.thread_id, - action, - }; + const entry = getSessionEntry(context, args.session_id); + await STEP_HANDLERS[action](entry.manager, args.thread_id); + return {thread_id: args.thread_id, action}; }, formatOutput: (output) => jsonResult(output), }, diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts index 2e2cd343c..f6ee4ee69 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/debug-wait-for-stop.ts @@ -9,7 +9,8 @@ import type {McpTool} from '../../utils/index.js'; import type {Services} from '../../services.js'; import type {ServerContext} from '../../server-context.js'; import {createToolAdapter, jsonResult} from '../adapter.js'; -import type {SdapiScriptThread} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {projectThreadLocation, type MappedLocation} from '@salesforce/b2c-tooling-sdk/operations/debug'; +import {getRegistry, getSessionEntry} from './session-registry.js'; const DEFAULT_TIMEOUT_MS = 30_000; const MAX_TIMEOUT_MS = 120_000; @@ -23,12 +24,7 @@ interface WaitForStopOutput { halted: boolean; timed_out?: boolean; thread_id?: number; - location?: { - file: null | string; - line: number; - function_name: string; - script_path: string; - }; + location?: MappedLocation; } export function createDebugWaitForStopTool( @@ -40,9 +36,8 @@ export function createDebugWaitForStopTool( name: 'debug_wait_for_stop', description: 'Wait for a thread to halt at a breakpoint or step. ' + - 'Returns immediately if a thread is already halted. ' + - 'Otherwise BLOCKS until a halt occurs or the timeout expires. ' + - 'Preferred non-blocking alternative: after debug_set_breakpoints, trigger the request yourself, then use debug_list_sessions to check if halted_threads is non-empty before calling debug_get_stack/debug_get_variables.', + 'Returns immediately if a thread is already halted; otherwise BLOCKS until a halt occurs or the timeout expires. ' + + 'Preferred non-blocking alternative: after debug_set_breakpoints, trigger the request yourself, then use debug_list_sessions to check halted_threads before calling debug_get_stack/debug_get_variables.', toolsets: ['CARTRIDGES', 'SCAPI'], inputSchema: { session_id: z.string().describe('Session ID returned by debug_start_session.'), @@ -55,36 +50,18 @@ export function createDebugWaitForStopTool( .describe(`Timeout in milliseconds (default: ${DEFAULT_TIMEOUT_MS}, max: ${MAX_TIMEOUT_MS}).`), }, async execute(args, context) { - const registry = context.serverContext?.debugSessions; - if (!registry) { - throw new Error('Debug session registry not available'); - } - - const entry = registry.getSessionOrThrow(args.session_id); + const entry = getSessionEntry(context, args.session_id); const timeout = Math.min(args.timeout_ms ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS); - // Check if any thread is already halted - const haltedThread = entry.manager.getKnownThreads().find((t) => t.status === 'halted'); - if (haltedThread) { - return formatHaltResult(haltedThread, entry.sourceMapper); - } - - // Wait for a halt via the callback mechanism - const thread = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - const idx = entry.haltWaiters.findIndex((w) => w.timer === timer); - if (idx !== -1) entry.haltWaiters.splice(idx, 1); - resolve(null); - }, timeout); - - entry.haltWaiters.push({resolve: (t) => resolve(t), reject, timer}); - }); + const thread = await getRegistry(context).waitForHalt(entry, timeout); + if (!thread) return {halted: false, timed_out: true}; - if (!thread) { - return {halted: false, timed_out: true}; - } - - return formatHaltResult(thread, entry.sourceMapper); + const location = projectThreadLocation(thread, entry.sourceMapper); + return { + halted: true, + thread_id: thread.id, + location: location ?? undefined, + }; }, formatOutput: (output) => jsonResult(output), }, @@ -92,23 +69,3 @@ export function createDebugWaitForStopTool( serverContext, ); } - -function formatHaltResult( - thread: SdapiScriptThread, - sourceMapper: {toLocalPath: (path: string) => string | undefined}, -): WaitForStopOutput { - const topFrame = thread.call_stack?.[0]; - const loc = topFrame?.location; - return { - halted: true, - thread_id: thread.id, - location: loc - ? { - file: sourceMapper.toLocalPath(loc.script_path) ?? null, - line: loc.line_number, - function_name: loc.function_name, - script_path: loc.script_path, - } - : undefined, - }; -} diff --git a/packages/b2c-dx-mcp/src/tools/diagnostics/session-registry.ts b/packages/b2c-dx-mcp/src/tools/diagnostics/session-registry.ts index c7954bffe..a5c0b4765 100644 --- a/packages/b2c-dx-mcp/src/tools/diagnostics/session-registry.ts +++ b/packages/b2c-dx-mcp/src/tools/diagnostics/session-registry.ts @@ -13,6 +13,7 @@ import type { SdapiScriptThread, } from '@salesforce/b2c-tooling-sdk/operations/debug'; import type {CartridgeMapping} from '@salesforce/b2c-tooling-sdk/operations/code'; +import type {ToolExecutionContext} from '../adapter.js'; const IDLE_TTL_MS = 30 * 60 * 1000; // 30 minutes const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes @@ -36,6 +37,14 @@ export interface DebugSessionEntry { lastActivityAt: number; } +export interface RegisterSessionOptions { + hostname: string; + clientId: string; + manager: DebugSessionManager; + sourceMapper: SourceMapper; + cartridges: CartridgeMapping[]; +} + export class DebugSessionRegistry { private cleanupTimer: ReturnType | undefined; private readonly sessions = new Map(); @@ -102,13 +111,8 @@ export class DebugSessionRegistry { return [...this.sessions.values()]; } - registerSession( - hostname: string, - clientId: string, - manager: DebugSessionManager, - sourceMapper: SourceMapper, - cartridges: CartridgeMapping[], - ): DebugSessionEntry { + registerSession(opts: RegisterSessionOptions): DebugSessionEntry { + const {hostname, clientId, manager, sourceMapper, cartridges} = opts; const existing = this.findByHostAndClientId(hostname, clientId); if (existing) { throw new Error( @@ -136,6 +140,27 @@ export class DebugSessionRegistry { return entry; } + /** + * Wait for any thread in the session to halt. + * + * If a thread is already halted in the session's known threads, returns it + * immediately. Otherwise registers a halt waiter that resolves when the + * `onThreadStopped` callback fires, or returns null on timeout. + */ + async waitForHalt(entry: DebugSessionEntry, timeoutMs: number): Promise { + const halted = entry.manager.getKnownThreads().find((t) => t.status === 'halted'); + if (halted) return halted; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = entry.haltWaiters.findIndex((w) => w.timer === timer); + if (idx !== -1) entry.haltWaiters.splice(idx, 1); + resolve(null); + }, timeoutMs); + entry.haltWaiters.push({resolve: (t) => resolve(t), reject, timer}); + }); + } + private async cleanupIdleSessions(): Promise { const logger = getLogger(); const now = Date.now(); @@ -150,3 +175,25 @@ export class DebugSessionRegistry { ); } } + +/** + * Resolve the registry from the tool execution context, throwing a clear + * error if it's missing, then look up the session by ID. Used by every + * debug tool that takes a session_id. + */ +export function getSessionEntry(context: ToolExecutionContext, sessionId: string): DebugSessionEntry { + const registry = context.serverContext?.debugSessions; + if (!registry) { + throw new Error('Debug session registry not available'); + } + return registry.getSessionOrThrow(sessionId); +} + +/** Resolve the registry from the tool context (for tools that don't need a specific session). */ +export function getRegistry(context: ToolExecutionContext): DebugSessionRegistry { + const registry = context.serverContext?.debugSessions; + if (!registry) { + throw new Error('Debug session registry not available'); + } + return registry; +} diff --git a/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts b/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts index e094190cd..771140932 100644 --- a/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts +++ b/packages/b2c-dx-mcp/test/tools/diagnostics/debug-tools.test.ts @@ -139,7 +139,13 @@ describe('tools/diagnostics', () => { getKnownThreads: sinon.stub().returns([{id: 5, status: 'halted', call_stack: []}]), }); const sourceMapper = createMockSourceMapper(); - const entry = serverContext.debugSessions.registerSession('host.example.com', 'c1', manager, sourceMapper, []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host.example.com', + clientId: 'c1', + manager, + sourceMapper, + cartridges: [], + }); entry.breakpoints = [{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]; const tool = createDebugListSessionsTool(loadServices, serverContext); @@ -165,7 +171,13 @@ describe('tools/diagnostics', () => { describe('debug_end_session', () => { it('should disconnect and remove the session', async () => { const manager = createMockManager(); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugEndSessionTool(loadServices, serverContext); const result = await tool.handler({session_id: entry.sessionId}); @@ -179,7 +191,13 @@ describe('tools/diagnostics', () => { it('should clear breakpoints when requested', async () => { const manager = createMockManager(); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugEndSessionTool(loadServices, serverContext); await tool.handler({session_id: entry.sessionId, clear_breakpoints: true}); @@ -190,7 +208,13 @@ describe('tools/diagnostics', () => { it('should handle deleteBreakpoints failure silently', async () => { const manager = createMockManager(); (manager.client.deleteBreakpoints as sinon.SinonStub).rejects(new Error('SDAPI down')); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugEndSessionTool(loadServices, serverContext); const result = await tool.handler({session_id: entry.sessionId, clear_breakpoints: true}); @@ -216,7 +240,13 @@ describe('tools/diagnostics', () => { describe('debug_continue', () => { it('should resume the specified thread', async () => { const manager = createMockManager(); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugContinueTool(loadServices, serverContext); const result = await tool.handler({session_id: entry.sessionId, thread_id: 5}); @@ -238,7 +268,13 @@ describe('tools/diagnostics', () => { describe('debug_get_stack', () => { it('should return mapped stack frames', async () => { const manager = createMockManager(); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugGetStackTool(loadServices, serverContext); const result = await tool.handler({session_id: entry.sessionId, thread_id: 1}); @@ -261,7 +297,13 @@ describe('tools/diagnostics', () => { describe('debug_evaluate', () => { it('should evaluate expression and return result', async () => { const manager = createMockManager(); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugEvaluateTool(loadServices, serverContext); const result = await tool.handler({session_id: entry.sessionId, thread_id: 1, expression: 'x'}); @@ -274,7 +316,13 @@ describe('tools/diagnostics', () => { it('should use frame_index 0 by default', async () => { const manager = createMockManager(); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugEvaluateTool(loadServices, serverContext); await tool.handler({session_id: entry.sessionId, thread_id: 1, expression: 'x'}); @@ -284,7 +332,13 @@ describe('tools/diagnostics', () => { it('should use specified frame_index', async () => { const manager = createMockManager(); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugEvaluateTool(loadServices, serverContext); await tool.handler({session_id: entry.sessionId, thread_id: 1, frame_index: 2, expression: 'y'}); @@ -302,7 +356,13 @@ describe('tools/diagnostics', () => { describe('debug_get_variables', () => { it('should return variables with has_children flag based on type', async () => { const manager = createMockManager(); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugGetVariablesTool(loadServices, serverContext); const result = await tool.handler({session_id: entry.sessionId, thread_id: 1}); @@ -320,7 +380,13 @@ describe('tools/diagnostics', () => { it('should filter by scope', async () => { const manager = createMockManager(); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugGetVariablesTool(loadServices, serverContext); const result = await tool.handler({session_id: entry.sessionId, thread_id: 1, scope: 'global'}); @@ -332,7 +398,13 @@ describe('tools/diagnostics', () => { it('should use getMembers when object_path is provided', async () => { const manager = createMockManager(); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugGetVariablesTool(loadServices, serverContext); const result = await tool.handler({session_id: entry.sessionId, thread_id: 1, object_path: 'obj'}); @@ -360,7 +432,13 @@ describe('tools/diagnostics', () => { deleteBreakpoints: sinon.stub(), }, }); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugGetVariablesTool(loadServices, serverContext); const result = await tool.handler({session_id: entry.sessionId, thread_id: 1}); @@ -384,7 +462,13 @@ describe('tools/diagnostics', () => { .stub() .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), }); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugSetBreakpointsTool(loadServices, serverContext); const result = await tool.handler({ @@ -402,7 +486,13 @@ describe('tools/diagnostics', () => { const manager = createMockManager({ setBreakpoints: sinon.stub().resolves([{id: 1, line_number: 10, script_path: '/unknown/cartridge/foo.js'}]), }); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugSetBreakpointsTool(loadServices, serverContext); const result = await tool.handler({ @@ -423,7 +513,13 @@ describe('tools/diagnostics', () => { {id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js', condition: 'x > 5'}, ]); const manager = createMockManager({setBreakpoints: setBreakpointsStub}); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugSetBreakpointsTool(loadServices, serverContext); await tool.handler({ @@ -451,7 +547,13 @@ describe('tools/diagnostics', () => { it('step_over should call manager.stepOver', async () => { const manager = createMockManager(); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const [stepOver] = createDebugStepTools(loadServices, serverContext); const result = await stepOver.handler({session_id: entry.sessionId, thread_id: 3}); @@ -464,7 +566,13 @@ describe('tools/diagnostics', () => { it('step_into should call manager.stepInto', async () => { const manager = createMockManager(); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const [, stepInto] = createDebugStepTools(loadServices, serverContext); await stepInto.handler({session_id: entry.sessionId, thread_id: 3}); @@ -474,7 +582,13 @@ describe('tools/diagnostics', () => { it('step_out should call manager.stepOut', async () => { const manager = createMockManager(); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const stepOut = createDebugStepTools(loadServices, serverContext)[2]; await stepOut.handler({session_id: entry.sessionId, thread_id: 3}); @@ -502,7 +616,13 @@ describe('tools/diagnostics', () => { ], }; const manager = createMockManager({getKnownThreads: sinon.stub().returns([haltedThread])}); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugWaitForStopTool(loadServices, serverContext); const result = await tool.handler({session_id: entry.sessionId}); @@ -518,7 +638,13 @@ describe('tools/diagnostics', () => { const manager = createMockManager({ getKnownThreads: sinon.stub().returns([{id: 5, status: 'halted', call_stack: []}]), }); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugWaitForStopTool(loadServices, serverContext); const result = await tool.handler({session_id: entry.sessionId}); @@ -530,7 +656,13 @@ describe('tools/diagnostics', () => { it('should time out when no halt occurs', async () => { const manager = createMockManager(); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugWaitForStopTool(loadServices, serverContext); const result = await tool.handler({session_id: entry.sessionId, timeout_ms: 50}); @@ -542,7 +674,13 @@ describe('tools/diagnostics', () => { it('should resolve when onThreadStopped callback fires', async () => { const manager = createMockManager(); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugWaitForStopTool(loadServices, serverContext); const promise = tool.handler({session_id: entry.sessionId, timeout_ms: 5000}); @@ -594,7 +732,13 @@ describe('tools/diagnostics', () => { .stub() .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), }); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); const result = await tool.handler({ @@ -641,7 +785,13 @@ describe('tools/diagnostics', () => { .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), }); (manager.client.evaluate as sinon.SinonStub).rejects(new Error('bad expression')); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); const result = await tool.handler({ @@ -661,7 +811,13 @@ describe('tools/diagnostics', () => { .stub() .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), }); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); const result = await tool.handler({ @@ -693,7 +849,13 @@ describe('tools/diagnostics', () => { .stub() .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), }); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const fetchStub = sinon.stub(globalThis, 'fetch').resolves(new Response('', {status: 200})); @@ -731,7 +893,13 @@ describe('tools/diagnostics', () => { .stub() .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), }); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const fetchStub = sinon.stub(globalThis, 'fetch').rejects(new Error('network')); @@ -757,7 +925,13 @@ describe('tools/diagnostics', () => { .stub() .resolves([{id: 1, line_number: 42, script_path: '/app_test/cartridge/controllers/Cart.js'}]), }); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); const promise = tool.handler({ @@ -813,7 +987,13 @@ describe('tools/diagnostics', () => { total: 1, _v: '2.0', }); - const entry = serverContext.debugSessions.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = serverContext.debugSessions.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const tool = createDebugCaptureAtBreakpointTool(loadServices, serverContext); const result = await tool.handler({ diff --git a/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts b/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts index ce52dd0b8..25a89a427 100644 --- a/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts +++ b/packages/b2c-dx-mcp/test/tools/diagnostics/session-registry.test.ts @@ -47,7 +47,13 @@ describe('DebugSessionRegistry', () => { const manager = createMockManager(); const sourceMapper = createMockSourceMapper(); - const entry = registry.registerSession('host.example.com', 'client-1', manager, sourceMapper, []); + const entry = registry.registerSession({ + hostname: 'host.example.com', + clientId: 'client-1', + manager, + sourceMapper, + cartridges: [], + }); expect(entry.sessionId).to.be.a('string'); expect(entry.sessionId).to.match(/^[\da-f-]{36}$/); @@ -63,18 +69,42 @@ describe('DebugSessionRegistry', () => { const manager = createMockManager(); const sourceMapper = createMockSourceMapper(); - registry.registerSession('host.example.com', 'client-1', manager, sourceMapper, []); + registry.registerSession({ + hostname: 'host.example.com', + clientId: 'client-1', + manager, + sourceMapper, + cartridges: [], + }); expect(() => { - registry.registerSession('host.example.com', 'client-1', createMockManager(), createMockSourceMapper(), []); + registry.registerSession({ + hostname: 'host.example.com', + clientId: 'client-1', + manager: createMockManager(), + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); }).to.throw(/already exists.*client-1/); }); it('should allow different client IDs on same host', () => { const sourceMapper = createMockSourceMapper(); - registry.registerSession('host.example.com', 'client-1', createMockManager(), sourceMapper, []); - const entry2 = registry.registerSession('host.example.com', 'client-2', createMockManager(), sourceMapper, []); + registry.registerSession({ + hostname: 'host.example.com', + clientId: 'client-1', + manager: createMockManager(), + sourceMapper, + cartridges: [], + }); + const entry2 = registry.registerSession({ + hostname: 'host.example.com', + clientId: 'client-2', + manager: createMockManager(), + sourceMapper, + cartridges: [], + }); expect(entry2.sessionId).to.be.a('string'); }); @@ -82,8 +112,20 @@ describe('DebugSessionRegistry', () => { it('should allow same client ID on different hosts', () => { const sourceMapper = createMockSourceMapper(); - registry.registerSession('host1.example.com', 'client-1', createMockManager(), sourceMapper, []); - const entry2 = registry.registerSession('host2.example.com', 'client-1', createMockManager(), sourceMapper, []); + registry.registerSession({ + hostname: 'host1.example.com', + clientId: 'client-1', + manager: createMockManager(), + sourceMapper, + cartridges: [], + }); + const entry2 = registry.registerSession({ + hostname: 'host2.example.com', + clientId: 'client-1', + manager: createMockManager(), + sourceMapper, + cartridges: [], + }); expect(entry2.sessionId).to.be.a('string'); }); @@ -95,13 +137,13 @@ describe('DebugSessionRegistry', () => { }); it('should return the session entry', () => { - const entry = registry.registerSession( - 'host.example.com', - 'c', - createMockManager(), - createMockSourceMapper(), - [], - ); + const entry = registry.registerSession({ + hostname: 'host.example.com', + clientId: 'c', + manager: createMockManager(), + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); expect(registry.getSession(entry.sessionId)).to.equal(entry); }); }); @@ -112,13 +154,13 @@ describe('DebugSessionRegistry', () => { }); it('should return entry and update lastActivityAt', () => { - const entry = registry.registerSession( - 'host.example.com', - 'c', - createMockManager(), - createMockSourceMapper(), - [], - ); + const entry = registry.registerSession({ + hostname: 'host.example.com', + clientId: 'c', + manager: createMockManager(), + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const before = entry.lastActivityAt; // Small delay to ensure timestamp changes @@ -135,13 +177,13 @@ describe('DebugSessionRegistry', () => { }); it('should find matching entry', () => { - const entry = registry.registerSession( - 'host.example.com', - 'c', - createMockManager(), - createMockSourceMapper(), - [], - ); + const entry = registry.registerSession({ + hostname: 'host.example.com', + clientId: 'c', + manager: createMockManager(), + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); expect(registry.findByHostAndClientId('host.example.com', 'c')).to.equal(entry); }); }); @@ -152,8 +194,20 @@ describe('DebugSessionRegistry', () => { }); it('should return all sessions', () => { - registry.registerSession('h1', 'c1', createMockManager(), createMockSourceMapper(), []); - registry.registerSession('h2', 'c2', createMockManager(), createMockSourceMapper(), []); + registry.registerSession({ + hostname: 'h1', + clientId: 'c1', + manager: createMockManager(), + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + registry.registerSession({ + hostname: 'h2', + clientId: 'c2', + manager: createMockManager(), + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const list = registry.listSessions(); expect(list).to.have.lengthOf(2); @@ -163,7 +217,13 @@ describe('DebugSessionRegistry', () => { describe('destroySession', () => { it('should disconnect the manager', async () => { const manager = createMockManager(); - const entry = registry.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = registry.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); await registry.destroySession(entry.sessionId); @@ -172,7 +232,13 @@ describe('DebugSessionRegistry', () => { }); it('should reject pending halt waiters', async () => { - const entry = registry.registerSession('host', 'c', createMockManager(), createMockSourceMapper(), []); + const entry = registry.registerSession({ + hostname: 'host', + clientId: 'c', + manager: createMockManager(), + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); const waiterPromise = new Promise((resolve, reject) => { const timer = setTimeout(() => resolve('timeout'), 5000); @@ -205,7 +271,13 @@ describe('DebugSessionRegistry', () => { it('should swallow disconnect errors (best-effort cleanup)', async () => { const manager = createMockManager({disconnect: sinon.stub().rejects(new Error('boom'))}); - const entry = registry.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = registry.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); await registry.destroySession(entry.sessionId); // Should not throw expect(registry.getSession(entry.sessionId)).to.be.undefined; @@ -215,7 +287,13 @@ describe('DebugSessionRegistry', () => { describe('cleanupIdleSessions', () => { it('should destroy sessions idle past TTL', async () => { const manager = createMockManager(); - const entry = registry.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = registry.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); // Fake an ancient lastActivityAt entry.lastActivityAt = Date.now() - 31 * 60 * 1000; @@ -229,7 +307,13 @@ describe('DebugSessionRegistry', () => { it('should leave active sessions alone', async () => { const manager = createMockManager(); - const entry = registry.registerSession('host', 'c', manager, createMockSourceMapper(), []); + const entry = registry.registerSession({ + hostname: 'host', + clientId: 'c', + manager, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); await (registry as unknown as {cleanupIdleSessions: () => Promise}).cleanupIdleSessions(); @@ -241,8 +325,20 @@ describe('DebugSessionRegistry', () => { it('should destroy all sessions', async () => { const m1 = createMockManager(); const m2 = createMockManager(); - registry.registerSession('h1', 'c1', m1, createMockSourceMapper(), []); - registry.registerSession('h2', 'c2', m2, createMockSourceMapper(), []); + registry.registerSession({ + hostname: 'h1', + clientId: 'c1', + manager: m1, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); + registry.registerSession({ + hostname: 'h2', + clientId: 'c2', + manager: m2, + sourceMapper: createMockSourceMapper(), + cartridges: [], + }); await registry.destroyAll(); diff --git a/packages/b2c-tooling-sdk/src/operations/debug/index.ts b/packages/b2c-tooling-sdk/src/operations/debug/index.ts index 604e359f7..52a26a4eb 100644 --- a/packages/b2c-tooling-sdk/src/operations/debug/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/debug/index.ts @@ -19,6 +19,16 @@ export {createSourceMapper} from './source-mapping.js'; export type {SourceMapper} from './source-mapping.js'; export {VariableStore} from './variable-store.js'; export {resolveBreakpointPath} from './resolve-path.js'; +export { + DEFAULT_MAX_VALUE_LENGTH, + isPrimitiveType, + projectBreakpoint, + projectFrame, + projectThreadLocation, + projectVariable, + truncateValue, +} from './projections.js'; +export type {MappedBreakpoint, MappedFrame, MappedLocation, MappedVariable} from './projections.js'; export type {VariableRef} from './variable-store.js'; export type { SdapiLocation, diff --git a/packages/b2c-tooling-sdk/src/operations/debug/projections.ts b/packages/b2c-tooling-sdk/src/operations/debug/projections.ts new file mode 100644 index 000000000..94ec68886 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/operations/debug/projections.ts @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +/** + * Shared projections that map SDAPI response types to JSON-friendly objects + * with both server paths and resolved local file paths. Used by both the MCP + * debugger tools and the CLI RPC mode. + * + * @module operations/debug/projections + */ + +import type {SourceMapper} from './source-mapping.js'; +import type {SdapiBreakpoint, SdapiObjectMember, SdapiScriptThread, SdapiStackFrame} from './types.js'; + +/** Default maximum length for variable values before truncation. */ +export const DEFAULT_MAX_VALUE_LENGTH = 200; + +/** + * SDAPI primitive type names. Variables of these types are not expandable + * (no children to drill into). + */ +const PRIMITIVE_TYPES = new Set(['boolean', 'Boolean', 'null', 'number', 'Number', 'string', 'String', 'undefined']); + +/** A mapped source location with both server and local file paths. */ +export interface MappedLocation { + file: null | string; + function_name: string; + line: number; + script_path: string; +} + +/** A mapped stack frame. */ +export interface MappedFrame { + file: null | string; + function_name: string; + index: number; + line: number; + script_path: string; +} + +/** A mapped variable for inspection. */ +export interface MappedVariable { + has_children: boolean; + name: string; + scope?: string; + type: string; + value: string; +} + +/** A mapped breakpoint. */ +export interface MappedBreakpoint { + condition?: string; + file: null | string; + id: number; + line: number; + script_path: string; +} + +/** True when the SDAPI type name represents a non-expandable primitive value. */ +export function isPrimitiveType(type: string): boolean { + return PRIMITIVE_TYPES.has(type); +} + +/** + * Truncate a string to at most `max` characters, appending `...` if truncated. + */ +export function truncateValue(value: string, max: number = DEFAULT_MAX_VALUE_LENGTH): string { + if (value.length <= max) return value; + return value.slice(0, max) + '...'; +} + +/** + * Project an SDAPI stack frame to a structured object with mapped local file path. + */ +export function projectFrame(frame: SdapiStackFrame, mapper: SourceMapper): MappedFrame { + return { + file: mapper.toLocalPath(frame.location.script_path) ?? null, + function_name: frame.location.function_name, + index: frame.index, + line: frame.location.line_number, + script_path: frame.location.script_path, + }; +} + +/** + * Project an SDAPI object member to a structured variable. + * + * @param member - The SDAPI object member + * @param options.includeScope - Whether to include the scope field (defaults to true) + * @param options.maxValueLength - Maximum value length before truncation + */ +export function projectVariable( + member: SdapiObjectMember, + options: {includeScope?: boolean; maxValueLength?: number} = {}, +): MappedVariable { + const {includeScope = true, maxValueLength = DEFAULT_MAX_VALUE_LENGTH} = options; + const result: MappedVariable = { + has_children: !isPrimitiveType(member.type), + name: member.name, + type: member.type, + value: truncateValue(member.value, maxValueLength), + }; + if (includeScope) result.scope = member.scope; + return result; +} + +/** + * Project an SDAPI breakpoint to a structured object with mapped local file path. + */ +export function projectBreakpoint(bp: SdapiBreakpoint, mapper: SourceMapper): MappedBreakpoint { + return { + condition: bp.condition, + file: mapper.toLocalPath(bp.script_path) ?? null, + id: bp.id, + line: bp.line_number, + script_path: bp.script_path, + }; +} + +/** + * Project the top-of-stack location for a thread, mapping the script path. + * Returns null if the thread has no call stack. + */ +export function projectThreadLocation(thread: SdapiScriptThread, mapper: SourceMapper): MappedLocation | null { + const topFrame = thread.call_stack?.[0]; + if (!topFrame) return null; + const loc = topFrame.location; + return { + file: mapper.toLocalPath(loc.script_path) ?? null, + function_name: loc.function_name, + line: loc.line_number, + script_path: loc.script_path, + }; +} From 3860c39ec0bb835c1a9bd5e883b26a422686cc79 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 7 May 2026 13:47:31 -0400 Subject: [PATCH 14/14] Align diagnostics auth section with other MCP tool docs Match the cartridge-deploy.md pattern: structured Required block, Configuration priority line, link to ../configuration (MCP config) and ../../guide/authentication#webdav-access (main auth guide with WebDAV anchor). --- docs/mcp/tools/diagnostics.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/mcp/tools/diagnostics.md b/docs/mcp/tools/diagnostics.md index b244cc63b..6f78d862f 100644 --- a/docs/mcp/tools/diagnostics.md +++ b/docs/mcp/tools/diagnostics.md @@ -8,11 +8,16 @@ MCP tools for connecting to the B2C Commerce Script Debugger API (SDAPI), settin ## Authentication -All debug tools require **Basic Auth** credentials (username and password) for a Business Manager user with the `WebDAV_Manage_Customization` permission. +Requires **Basic Auth** credentials only. OAuth is not supported by the SDAPI. + +**Required:** +- **Basic Auth** - `hostname`, `username`, and `password` (WebDAV access key) for a Business Manager user with the `WebDAV_Manage_Customization` permission. + +**Configuration priority:** Flags → Environment variables → `dw.json` config file The script debugger must also be enabled on the instance: Business Manager > Administration > Development Configuration > Script Debugger > Enable. -See the [Authentication guide](../../guide/authentication) and [Configuration guide](../../guide/configuration) for credential setup details. +See [Configuration](../configuration) for complete credential setup details including flags and environment variables. See [Authentication Setup](../../guide/authentication#webdav-access) for WebDAV access key configuration instructions. ## Recovery from broken or orphaned sessions