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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/mcp-logs-and-docs-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@salesforce/b2c-dx-mcp': minor
'@salesforce/b2c-tooling-sdk': minor
---

Add MCP tools for log inspection and documentation lookup. Logs: `logs_list_files`, `logs_get_recent`, and a `logs_watch_start` / `logs_watch_poll` / `logs_watch_stop` / `logs_watch_list` lifecycle that buffers entries between polls so agents don't miss logs produced between tool calls. Docs: `docs_search`, `docs_read`, `docs_list`, `docs_schema_search`, `docs_schema_read`, `docs_schema_list` for the bundled Script API and XSD schema corpora. Adds a new `DIAGNOSTICS` toolset that groups the script debugger and log tools. SDK now also exports the log filter helpers (`parseSinceTime`, `filterBySince`, `filterByLevel`, `filterBySearch`, `matchesLevel`, `matchesSearch`) for reuse.
10 changes: 9 additions & 1 deletion docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,15 @@ const referenceSidebar = [
{
text: 'Diagnostics',
collapsed: true,
items: [{text: 'Script Debugger', link: '/mcp/tools/diagnostics'}],
items: [
{text: 'Script Debugger', link: '/mcp/tools/diagnostics'},
{text: 'Logs', link: '/mcp/tools/logs'},
],
},
{
text: 'Documentation',
collapsed: true,
items: [{text: 'Script API & Schemas', link: '/mcp/tools/docs'}],
},
],
},
Expand Down
89 changes: 89 additions & 0 deletions docs/mcp/tools/docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
description: MCP tools for searching B2C Commerce Script API and XSD schema documentation.
---

# Documentation Tools

MCP tools for searching and reading the bundled B2C Commerce Script API documentation and XSD schemas. These tools read data shipped with the SDK — they do **not** require any instance configuration or authentication. Available in **every toolset**.

## When to use which tool

- Look up a class or module — search first to confirm the id, then read.
- `docs_search` (fuzzy) → `docs_read` (full markdown).
- Look up an XSD schema for an import file (e.g., `system-objecttype-extensions.xml`) — same pattern.
- `docs_schema_search` → `docs_schema_read`.
- Browse all available entries — `docs_list` / `docs_schema_list`.

> **Note:** `docs_read` and `docs_schema_read` content can be large (full markdown files / full XSD bodies). Prefer `docs_search`/`docs_schema_search` to narrow down before reading.

---

## Script API

### docs_search

Fuzzy-search Script API documentation by class, module, or partial name.

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `query` | string | Yes | | Search query (e.g., `"ProductMgr"`, `"dw.catalog"`, `"Status"`) |
| `limit` | number | No | `20` | Maximum number of results |

**Returns:** `{query, results: [{entry: {id, title, filePath, preview?}, score}]}`. Lower `score` = better match.

### docs_read

Read full markdown documentation for a Script API class or module.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | Yes | Exact id (e.g., `"dw.catalog.ProductMgr"`) or fuzzy query |

**Returns:** `{entry: {id, title, filePath, preview?}, content: string}`. Returns an error result with a hint to use `docs_search` if no match is found.

### docs_list

List every available Script API documentation entry.

No parameters.

**Returns:** `{count, entries: [{id, title, filePath, preview?}]}`. Output is large; prefer `docs_search` for targeted lookups.

---

## XSD Schemas

### docs_schema_search

Fuzzy-search XSD schema ids.

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `query` | string | Yes | | Schema name or partial match (e.g., `"catalog"`, `"order"`) |
| `limit` | number | No | `20` | Maximum number of results |

**Returns:** `{query, results: [{entry: {id, filePath}, score}]}`.

### docs_schema_read

Read the contents of an XSD schema (raw XML) plus the on-disk path.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | Yes | Exact id or fuzzy query |

**Returns:** `{entry: {id, filePath}, content: string, path: string}`. Returns an error result with a hint to use `docs_schema_search` if no match.

### docs_schema_list

List every available XSD schema id.

No parameters.

**Returns:** `{count, entries: [{id, filePath}]}`.

---

## See also

- [Docs CLI commands](/cli/docs) — `b2c docs search` / `read` / `schema`
122 changes: 122 additions & 0 deletions docs/mcp/tools/logs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
---
description: MCP tools for fetching and tailing logs on B2C Commerce instances.
---

# Log Tools

MCP tools for inspecting runtime logs on a B2C Commerce instance via WebDAV. Use them to investigate errors after triggering a request, monitor a job run, or audit recent failures. Available in the **CARTRIDGES**, **DIAGNOSTICS**, and **SCAPI** toolsets.

## Authentication

All log tools that read from the instance (`logs_list_files`, `logs_get_recent`, `logs_watch_start`) require WebDAV-capable credentials.

**Required:**
- **Basic Auth** preferred: `hostname`, `username`, and `password` (WebDAV access key) for a Business Manager user with WebDAV log read permission.
- OAuth (client-credentials / implicit) is also supported as a fallback for WebDAV.

**Configuration priority:** Flags → Environment variables → `dw.json` config file

`logs_watch_poll`, `logs_watch_stop`, and `logs_watch_list` operate on server-side state only and do not require fresh credentials per call.

See [Configuration](../configuration) for credential setup.

## When to use which tool

- Quick lookup of recent errors → **`logs_get_recent`**.
- Discover what log prefixes are active on the instance → **`logs_list_files`**.
- Monitor logs across a multi-step action you trigger (storefront request, job, debug session) → start a watch with **`logs_watch_start`**, then drain it with **`logs_watch_poll`**, and finish with **`logs_watch_stop`**. The watch buffers entries between calls so nothing is lost between agent turns.

## Recovery from orphaned watches

Log watches are stateful and live in the MCP server process. Only one watch is allowed per hostname. If an agent loses track of an active watch:

1. **List active watches** — call `logs_watch_list` (no args). It returns all watches with their `watch_id`, `hostname`, `prefixes`, and buffered counts.
2. **Stop orphaned watches** — call `logs_watch_stop` with the `watch_id` to release the underlying tail.
3. **Idle cleanup** — watches inactive for 30 minutes are automatically destroyed.
4. **Restart the MCP server** — last resort; destroys all watch state.

---

## One-shot tools

### logs_list_files

List log files on the instance via WebDAV.

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `prefixes` | string[] | No | all | Filter by log prefix (e.g., `["error", "customerror"]`) |
| `sort_by` | `"date" \| "name" \| "size"` | No | `date` | Sort field |
| `sort_order` | `"asc" \| "desc"` | No | `desc` | Sort order |

**Returns:** `{count, files: [{name, prefix, size, lastModified, path}]}`.

### logs_get_recent

Fetch recent log entries in a single request/response. Filters (`since`, `level`, `search`) are applied client-side after fetching.

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `prefixes` | string[] | No | `["error", "customerror"]` | Log prefixes to read |
| `count` | number | No | `50` | Maximum entries to return |
| `since` | string | No | | Relative time (`"5m"`, `"1h"`, `"2d"`) or ISO 8601 |
| `level` | string[] | No | | Filter by level (ERROR, WARN, INFO, DEBUG, FATAL, TRACE) |
| `search` | string | No | | Case-insensitive substring filter |

**Returns:** `{count, entries: [{file, level, timestamp, message, raw}]}`.

---

## Watch lifecycle

### logs_watch_start

Start a background log watch. Returns a `watch_id` immediately. Buffers entries in memory until `logs_watch_poll` drains them.

> **Workflow:** call `logs_watch_start` **before** triggering the action that should produce logs. Otherwise startup may emit only existing entries (controlled by `last_entries`) and miss what you wanted to capture.

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `prefixes` | string[] | No | `["error", "customerror"]` | Log prefixes to watch |
| `last_entries` | number | No | `1` | Recent entries per file to emit on startup. `0` skips. |
| `poll_interval_ms` | number | No | `3000` | How often the underlying tail polls WebDAV |
| `level` | string[] | No | | Drop entries not matching level before buffering |
| `search` | string | No | | Drop entries not matching substring before buffering |

**Returns:** `{watch_id, hostname, prefixes, started_at}`.

### logs_watch_poll

Drain buffered entries. If the buffer is empty, blocks up to `timeout_ms` waiting for new entries.

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `watch_id` | string | Yes | | Watch id from `logs_watch_start` |
| `timeout_ms` | number | No | `5000` | Max time to block when buffer is empty. `0` returns immediately. |
| `max_entries` | number | No | `200` | Maximum entries returned per call |

**Returns:** `{watch_id, entries, files_discovered, files_rotated, errors, truncated, buffered_remaining, dropped_entries, stopped}`. When `truncated` is `true`, call again to drain the rest.

### logs_watch_stop

Stop a watch and release its underlying tail.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `watch_id` | string | Yes | Watch id from `logs_watch_start` |

**Returns:** `{watch_id, stopped_at, total_entries_seen}`.

### logs_watch_list

List active watches. Use to recover orphaned watches or inspect buffered counts.

No parameters.

**Returns:** `{watches: [{watch_id, hostname, prefixes, buffered_entries, total_entries_seen, dropped_entries, files_discovered, stopped, created_at, last_activity_at}]}`.

---

## See also

- [Logs CLI commands](/cli/logs) — `b2c logs tail` / `get` / `list`
128 changes: 10 additions & 118 deletions packages/b2c-cli/src/utils/logs/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,121 +4,13 @@
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
*/

import type {LogEntry} from '@salesforce/b2c-tooling-sdk/operations/logs';

/**
* Parses a relative time string (e.g., "5m", "1h", "2d") into milliseconds.
* Returns null if the string is not a valid relative time format.
*/
export function parseRelativeTime(timeStr: string): null | number {
const match = timeStr.match(/^(\d+)([mhd])$/i);
if (!match) {
return null;
}

const value = Number.parseInt(match[1], 10);
const unit = match[2].toLowerCase();

switch (unit) {
case 'd': {
return value * 24 * 60 * 60 * 1000;
}
case 'h': {
return value * 60 * 60 * 1000;
}
case 'm': {
return value * 60 * 1000;
}
default: {
return null;
}
}
}

/**
* Parses a --since value into a Date object.
* Supports:
* - Relative times: "5m", "1h", "2d"
* - ISO 8601: "2026-01-25T10:00:00"
*/
export function parseSinceTime(sinceStr: string): Date {
// Try relative time first
const relativeMs = parseRelativeTime(sinceStr);
if (relativeMs !== null) {
return new Date(Date.now() - relativeMs);
}

// Try ISO 8601
const date = new Date(sinceStr);
if (Number.isNaN(date.getTime())) {
throw new TypeError(
`Invalid --since value: "${sinceStr}". Use relative time (e.g., "5m", "1h", "2d") or ISO 8601 (e.g., "2026-01-25T10:00:00")`,
);
}

return date;
}

/**
* Parses a B2C log timestamp into a Date object.
* Expected format: "2025-01-25 10:30:45.123 GMT"
*/
export function parseLogTimestamp(timestamp: string): Date | null {
// B2C format: "2025-01-25 10:30:45.123 GMT"
// Convert to ISO format for parsing
const isoFormat = timestamp.replace(' GMT', 'Z').replace(' ', 'T');
const date = new Date(isoFormat);
return Number.isNaN(date.getTime()) ? null : date;
}

/**
* Filters entries by timestamp.
*/
export function filterBySince(entries: LogEntry[], since: Date): LogEntry[] {
return entries.filter((entry) => {
if (!entry.timestamp) return true; // Include entries without timestamps
const entryDate = parseLogTimestamp(entry.timestamp);
return entryDate === null || entryDate >= since;
});
}

/**
* Filters entries by log level.
*/
export function filterByLevel(entries: LogEntry[], levels: string[]): LogEntry[] {
const upperLevels = new Set(levels.map((l) => l.toUpperCase()));
return entries.filter((entry) => {
// Include entries without level if no specific level filter
if (!entry.level) return false;
return upperLevels.has(entry.level.toUpperCase());
});
}

/**
* Filters entries by text search (case-insensitive substring match).
*/
export function filterBySearch(entries: LogEntry[], search: string): LogEntry[] {
const lowerSearch = search.toLowerCase();
return entries.filter((entry) => {
return entry.message.toLowerCase().includes(lowerSearch) || entry.raw.toLowerCase().includes(lowerSearch);
});
}

/**
* Checks if a single entry matches the specified log levels.
* Used for streaming/tail scenarios where we filter one entry at a time.
*/
export function matchesLevel(entry: LogEntry, levels: string[]): boolean {
if (!entry.level) return false;
const upperLevels = new Set(levels.map((l) => l.toUpperCase()));
return upperLevels.has(entry.level.toUpperCase());
}

/**
* Checks if a single entry matches the search text (case-insensitive).
* Used for streaming/tail scenarios where we filter one entry at a time.
*/
export function matchesSearch(entry: LogEntry, search: string): boolean {
const lowerSearch = search.toLowerCase();
return entry.message.toLowerCase().includes(lowerSearch) || entry.raw.toLowerCase().includes(lowerSearch);
}
export {
filterByLevel,
filterBySearch,
filterBySince,
matchesLevel,
matchesSearch,
parseLogTimestamp,
parseRelativeTime,
parseSinceTime,
} from '@salesforce/b2c-tooling-sdk/operations/logs';
3 changes: 3 additions & 0 deletions packages/b2c-dx-mcp/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ 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 {createDocsTools} from './tools/docs/index.js';
import {createMrtTools} from './tools/mrt/index.js';
import {createPwav3Tools} from './tools/pwav3/index.js';
import {createScapiTools} from './tools/scapi/index.js';
Expand Down Expand Up @@ -87,6 +88,7 @@ export function createToolRegistry(
): ToolRegistry {
const registry: ToolRegistry = {
CARTRIDGES: [],
DIAGNOSTICS: [],
MRT: [],
PWAV3: [],
SCAPI: [],
Expand All @@ -97,6 +99,7 @@ export function createToolRegistry(
const allTools: McpTool[] = [
...createCartridgesTools(loadServices),
...createDiagnosticsTools(loadServices, serverContext),
...createDocsTools(loadServices),
...createMrtTools(loadServices),
...createPwav3Tools(loadServices),
...createScapiTools(loadServices),
Expand Down
Loading
Loading