feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* @file dashboard-api-client.ts
|
||||
* @description Client for making API requests to the MCP dashboard. This client provides methods for sending HTTP requests (GET, POST, PUT, PATCH, DELETE) to the dashboard's API endpoints, with built-in support for retries on transient errors, request timeouts, and error handling. The client constructs URLs based on a base URL from the configuration and allows for query parameters and request bodies. It also defines a custom ApiError class for consistent error representation across the application.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../config/app-config.js`
|
||||
* - `../core/logger.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ApiError` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `DashboardApiClient` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ApiError**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **DashboardApiClient**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import type { AppConfig } from "../config/app-config.js";
|
||||
import { Logger } from "../core/logger.js";
|
||||
|
||||
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
interface RequestOptions {
|
||||
/** Query params; `undefined`/`null` values are omitted, not stringified. */
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
/** Request body, JSON-stringified as-is; omitted when `undefined`. */
|
||||
body?: unknown;
|
||||
/** Marks the request retry-eligible; set only by `get`/`delete` below. */
|
||||
idempotent?: boolean;
|
||||
}
|
||||
|
||||
interface ApiErrorOptions {
|
||||
status?: number;
|
||||
code?: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error type for every failed dashboard API call — non-2xx responses,
|
||||
* timeouts, and network failures all normalize to this shape.
|
||||
* {@link errorResult} surfaces `code`/`status`/`details` to the MCP client
|
||||
* instead of collapsing to a generic internal error.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
status?: number;
|
||||
/** Forwarded from the dashboard's error envelope, a synthesized
|
||||
* `HTTP_<status>`, or this client's own code (`INVALID_PATH`, `TIMEOUT`,
|
||||
* `REQUEST_FAILED`, `UNREACHABLE_STATE`). */
|
||||
code?: string;
|
||||
details?: unknown;
|
||||
|
||||
constructor(message: string, options: ApiErrorOptions = {}) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = options.status;
|
||||
this.code = options.code;
|
||||
this.details = options.details;
|
||||
}
|
||||
}
|
||||
|
||||
/** True for a DOM/Node `AbortError` from {@link DashboardApiClient.request}'s
|
||||
* per-attempt timeout controller. */
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" && error !== null && "name" in error && error.name === "AbortError"
|
||||
);
|
||||
}
|
||||
|
||||
/** Statuses treated as transient/retryable: 408, 429, or any 5xx. */
|
||||
function isRetryableStatus(status: number): boolean {
|
||||
return status === 408 || status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin HTTP client every MCP tool handler uses to reach the dashboard's
|
||||
* local Express API — the sole network boundary of the server. Requests
|
||||
* resolve against `config.dashboardBaseUrl` and are restricted to `/api/*`
|
||||
* (see {@link buildUrl}).
|
||||
*
|
||||
* **Retry semantics**: only GET/DELETE mark themselves `idempotent`, so only
|
||||
* they retry automatically — `config.retryCount` extra attempts (default 2)
|
||||
* on a timeout or HTTP 408/429/5xx, each retry waiting
|
||||
* `config.retryBackoffMs * 2^(attempt-1)` (default 250ms, 500ms, ...,
|
||||
* exponential, no jitter). POST/PUT/PATCH are never retried, even for the
|
||||
* same transient statuses — a duplicated write is worse than one surfaced
|
||||
* failure.
|
||||
*/
|
||||
export class DashboardApiClient {
|
||||
constructor(
|
||||
private readonly config: AppConfig,
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/** GET — idempotent, eligible for automatic retry. */
|
||||
async get<T>(path: string, options: Omit<RequestOptions, "body"> = {}): Promise<T> {
|
||||
return this.request<T>("GET", path, { ...options, idempotent: true });
|
||||
}
|
||||
|
||||
/** POST — never retried; used for creates and mutation-gated actions. */
|
||||
async post<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
return this.request<T>("POST", path, options);
|
||||
}
|
||||
|
||||
/** PUT — full upsert semantics (e.g. pricing rules); never retried. */
|
||||
async put<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
return this.request<T>("PUT", path, options);
|
||||
}
|
||||
|
||||
/** PATCH — partial update; never retried. */
|
||||
async patch<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
return this.request<T>("PATCH", path, options);
|
||||
}
|
||||
|
||||
/** DELETE — idempotent, eligible for automatic retry. */
|
||||
async delete<T>(path: string, options: Omit<RequestOptions, "body"> = {}): Promise<T> {
|
||||
return this.request<T>("DELETE", path, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `path` against the dashboard base URL and applies query
|
||||
* params, enforcing that only `/api/*` paths can ever be requested — a
|
||||
* hard client-side allowlist independent of the dashboard's own routing.
|
||||
* @throws {ApiError} code `INVALID_PATH` if the resolved pathname doesn't
|
||||
* start with `/api/`.
|
||||
*/
|
||||
private buildUrl(path: string, query?: RequestOptions["query"]): URL {
|
||||
const url = new URL(path, this.config.dashboardBaseUrl);
|
||||
if (!url.pathname.startsWith("/api/")) {
|
||||
throw new ApiError(`Invalid path "${path}". MCP client can only call /api/* endpoints.`, {
|
||||
code: "INVALID_PATH",
|
||||
});
|
||||
}
|
||||
|
||||
if (query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core request implementation shared by all five methods. Each attempt
|
||||
* gets its own {@link AbortController} armed with `config.requestTimeoutMs`
|
||||
* and best-effort JSON-parses the response (see {@link tryParseJson}).
|
||||
* `maxAttempts` is `config.retryCount + 1` when `options.idempotent`,
|
||||
* else `1`. On error, {@link shouldRetry} decides whether to back off and
|
||||
* loop or fall through to normalization: a non-ok response becomes an
|
||||
* {@link ApiError} via {@link toApiError}; an abort becomes `TIMEOUT`; any
|
||||
* other throw becomes `REQUEST_FAILED`.
|
||||
* @throws {ApiError} on any non-2xx response, timeout, or network failure
|
||||
* surviving the retry loop.
|
||||
*/
|
||||
private async request<T>(method: HttpMethod, path: string, options: RequestOptions): Promise<T> {
|
||||
const maxAttempts = options.idempotent ? this.config.retryCount + 1 : 1;
|
||||
const url = this.buildUrl(path, options.query);
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => abortController.abort(), this.config.requestTimeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
const rawBody = await response.text();
|
||||
const body = rawBody ? this.tryParseJson(rawBody) : null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw this.toApiError(method, url, response.status, body ?? rawBody);
|
||||
}
|
||||
|
||||
return body as T;
|
||||
} catch (error) {
|
||||
if (this.shouldRetry(error, attempt, maxAttempts)) {
|
||||
const backoffMs = this.config.retryBackoffMs * Math.pow(2, attempt - 1);
|
||||
this.logger.warn("Transient API error, retrying", {
|
||||
method,
|
||||
path: url.toString(),
|
||||
attempt,
|
||||
maxAttempts,
|
||||
backoffMs,
|
||||
error: this.getErrorMessage(error),
|
||||
});
|
||||
await sleep(backoffMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (isAbortError(error)) {
|
||||
throw new ApiError(
|
||||
`Request timed out after ${this.config.requestTimeoutMs}ms: ${method} ${url.pathname}`,
|
||||
{ code: "TIMEOUT" }
|
||||
);
|
||||
}
|
||||
|
||||
throw new ApiError(`Request failed: ${method} ${url.pathname}`, {
|
||||
code: "REQUEST_FAILED",
|
||||
details: this.getErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
throw new ApiError("Unreachable request state", { code: "UNREACHABLE_STATE" });
|
||||
}
|
||||
|
||||
/** Never retries on the last attempt; always retries an abort/timeout;
|
||||
* for an {@link ApiError} with a status, retries only if
|
||||
* {@link isRetryableStatus}; any other exception type is treated as
|
||||
* transient too. */
|
||||
private shouldRetry(error: unknown, attempt: number, maxAttempts: number): boolean {
|
||||
if (attempt >= maxAttempts) return false;
|
||||
if (isAbortError(error)) return true;
|
||||
if (error instanceof ApiError && error.status !== undefined) {
|
||||
return isRetryableStatus(error.status);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Builds an {@link ApiError} from a non-ok response, preferring the
|
||||
* dashboard's `{ error: { code, message } }` envelope when present,
|
||||
* falling back to a generic `HTTP_<status>`. */
|
||||
private toApiError(method: HttpMethod, url: URL, status: number, body: unknown): ApiError {
|
||||
const fallbackMessage = `${method} ${url.pathname} failed with HTTP ${status}`;
|
||||
|
||||
if (
|
||||
body &&
|
||||
typeof body === "object" &&
|
||||
"error" in body &&
|
||||
body.error &&
|
||||
typeof body.error === "object" &&
|
||||
"message" in body.error
|
||||
) {
|
||||
const maybeCode =
|
||||
"code" in body.error && typeof body.error.code === "string" ? body.error.code : undefined;
|
||||
const maybeMessage =
|
||||
typeof body.error.message === "string" ? body.error.message : fallbackMessage;
|
||||
return new ApiError(maybeMessage, { status, code: maybeCode, details: body });
|
||||
}
|
||||
|
||||
return new ApiError(fallbackMessage, { status, code: `HTTP_${status}`, details: body });
|
||||
}
|
||||
|
||||
/** Parses `input` as JSON, returning the raw string unchanged if invalid. */
|
||||
private tryParseJson(input: string): unknown {
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalizes any thrown value to a loggable string message. */
|
||||
private getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === "string") return error;
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* @file app-config.ts
|
||||
* @description Module for loading and validating application configuration from environment variables. This module defines the AppConfig interface representing the configuration structure, along with functions to parse and validate individual configuration values such as booleans, integers, log levels, dashboard URLs, and transport modes. The loadConfig function aggregates all configuration values into a single AppConfig object, applying defaults and validation as needed. The module ensures that the application is configured correctly before it starts, providing clear error messages for invalid configurations.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `LogLevel` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TransportMode` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `AppConfig` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `loadConfig` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **LogLevel**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TransportMode**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **AppConfig**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **loadConfig**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
/** Minimum severity a log line must meet to be written to stderr; see {@link Logger}. */
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
/** Transport `index.ts` starts: `"stdio"` (default, MCP-host subprocess),
|
||||
* `"http"` (Streamable HTTP + legacy SSE server), or `"repl"` (interactive CLI). */
|
||||
export type TransportMode = "stdio" | "http" | "repl";
|
||||
|
||||
/**
|
||||
* Fully-resolved runtime configuration produced by {@link loadConfig}. Every
|
||||
* field has a safe default so the server boots with no env vars set.
|
||||
*/
|
||||
export interface AppConfig {
|
||||
/** From `MCP_SERVER_NAME`, default `"agent-dashboard-mcp"`. */
|
||||
serverName: string;
|
||||
/** From `MCP_SERVER_VERSION`, default `"1.0.0"`. */
|
||||
serverVersion: string;
|
||||
/** Base URL of the local dashboard API {@link DashboardApiClient} calls.
|
||||
* Must be http(s) targeting a loopback/local-container host (see
|
||||
* {@link parseDashboardUrl}) — a hard boundary against reaching a remote
|
||||
* origin. From `MCP_DASHBOARD_BASE_URL`, default `http://127.0.0.1:4820`. */
|
||||
dashboardBaseUrl: URL;
|
||||
/** Per-attempt timeout (ms) before a request aborts as `TIMEOUT`. From
|
||||
* `MCP_DASHBOARD_TIMEOUT_MS`, default `10_000`, clamped `[500, 120_000]`. */
|
||||
requestTimeoutMs: number;
|
||||
/** Extra attempts after the first for idempotent (GET/DELETE) requests on
|
||||
* a retryable error (timeout, HTTP 408/429/5xx); POST/PUT/PATCH always run
|
||||
* once. From `MCP_DASHBOARD_RETRY_COUNT`, default `2`, clamped `[0, 5]`. */
|
||||
retryCount: number;
|
||||
/** Base backoff delay (ms), doubled per retry (`* 2^(attempt-1)`). From
|
||||
* `MCP_DASHBOARD_RETRY_BACKOFF_MS`, default `250`, clamped `[50, 10_000]`. */
|
||||
retryBackoffMs: number;
|
||||
/** Master gate for every write tool; `false` makes the server read-only
|
||||
* (see `policy/tool-guards.ts`). From `MCP_DASHBOARD_ALLOW_MUTATIONS`,
|
||||
* default `false`. */
|
||||
allowMutations: boolean;
|
||||
/** Gate for `dashboard_clear_all_data` only; requires `allowMutations`
|
||||
* too. From `MCP_DASHBOARD_ALLOW_DESTRUCTIVE`, default `false`. */
|
||||
allowDestructive: boolean;
|
||||
/** From `MCP_LOG_LEVEL`, default `"info"`. */
|
||||
logLevel: LogLevel;
|
||||
/** Default transport before `index.ts`'s CLI-flag overrides. From
|
||||
* `MCP_TRANSPORT`, default `"stdio"`. */
|
||||
transport: TransportMode;
|
||||
/** HTTP transport bind port (ignored for stdio/repl). From
|
||||
* `MCP_HTTP_PORT`, default `8819`, clamped `[1, 65535]`. */
|
||||
httpPort: number;
|
||||
/** HTTP transport bind host (ignored for stdio/repl). From
|
||||
* `MCP_HTTP_HOST`, default `"127.0.0.1"`. */
|
||||
httpHost: string;
|
||||
}
|
||||
|
||||
/** Allowlist of hostnames the dashboard URL may target: loopback addresses
|
||||
* plus the special Docker/Podman host-mapping names, so the MCP server can
|
||||
* run containerized and still reach a dashboard on the host. Anything else
|
||||
* is rejected by {@link parseDashboardUrl}. */
|
||||
const LOCAL_DASHBOARD_HOSTS = new Set([
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
"::1",
|
||||
"host.docker.internal",
|
||||
"gateway.docker.internal",
|
||||
"host.containers.internal",
|
||||
]);
|
||||
const VALID_LOG_LEVELS = new Set<LogLevel>(["debug", "info", "warn", "error"]);
|
||||
|
||||
/** Parses `1/true/yes/on` / `0/false/no/off` (case-insensitive); anything
|
||||
* else, including `undefined`, resolves to `fallback`. */
|
||||
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
||||
if (value === undefined) return fallback;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
||||
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Parses and clamps an integer env var into `[min, max]`; non-numeric or
|
||||
* missing input falls back to `fallback` rather than throwing. */
|
||||
function parseInteger(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number
|
||||
): number {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
|
||||
/** Normalizes `MCP_LOG_LEVEL`, falling back to `"info"`. */
|
||||
function parseLogLevel(value: string | undefined): LogLevel {
|
||||
const normalized = value?.trim().toLowerCase() as LogLevel | undefined;
|
||||
return normalized && VALID_LOG_LEVELS.has(normalized) ? normalized : "info";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and validates `MCP_DASHBOARD_BASE_URL`. Unlike the other parsers
|
||||
* here, invalid input throws rather than falling back — an unsafe dashboard
|
||||
* target is startup-fatal, not something to paper over.
|
||||
* @throws {Error} on an invalid URL, a non-http(s) scheme, or a hostname
|
||||
* outside {@link LOCAL_DASHBOARD_HOSTS}.
|
||||
*/
|
||||
function parseDashboardUrl(raw: string | undefined): URL {
|
||||
const value = (raw ?? "http://127.0.0.1:4820").trim();
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`Invalid MCP_DASHBOARD_BASE_URL: "${value}"`);
|
||||
}
|
||||
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`MCP_DASHBOARD_BASE_URL must use http or https, received protocol "${url.protocol}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (!LOCAL_DASHBOARD_HOSTS.has(url.hostname)) {
|
||||
throw new Error(
|
||||
`MCP_DASHBOARD_BASE_URL must target a local dashboard host (${Array.from(LOCAL_DASHBOARD_HOSTS).join(", ")}). Received hostname "${url.hostname}".`
|
||||
);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/** Normalizes `MCP_TRANSPORT`, falling back to `"stdio"`. This is only the
|
||||
* default — `index.ts`'s `resolveTransport` may override it with CLI flags. */
|
||||
function parseTransport(value: string | undefined): TransportMode {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "http" || normalized === "repl" || normalized === "stdio") return normalized;
|
||||
return "stdio";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and normalizes all `MCP_*` env vars into one {@link AppConfig}.
|
||||
* Called once at startup in `index.ts`; the result is treated as immutable.
|
||||
* @param env Defaults to `process.env`; injectable for tests.
|
||||
* @throws {Error} if `MCP_DASHBOARD_BASE_URL` is set but invalid/non-local.
|
||||
*/
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
return {
|
||||
serverName: env.MCP_SERVER_NAME?.trim() || "agent-dashboard-mcp",
|
||||
serverVersion: env.MCP_SERVER_VERSION?.trim() || "1.0.0",
|
||||
dashboardBaseUrl: parseDashboardUrl(env.MCP_DASHBOARD_BASE_URL),
|
||||
requestTimeoutMs: parseInteger(env.MCP_DASHBOARD_TIMEOUT_MS, 10_000, 500, 120_000),
|
||||
retryCount: parseInteger(env.MCP_DASHBOARD_RETRY_COUNT, 2, 0, 5),
|
||||
retryBackoffMs: parseInteger(env.MCP_DASHBOARD_RETRY_BACKOFF_MS, 250, 50, 10_000),
|
||||
allowMutations: parseBoolean(env.MCP_DASHBOARD_ALLOW_MUTATIONS, false),
|
||||
allowDestructive: parseBoolean(env.MCP_DASHBOARD_ALLOW_DESTRUCTIVE, false),
|
||||
logLevel: parseLogLevel(env.MCP_LOG_LEVEL),
|
||||
transport: parseTransport(env.MCP_TRANSPORT),
|
||||
httpPort: parseInteger(env.MCP_HTTP_PORT, 8819, 1, 65535),
|
||||
httpHost: env.MCP_HTTP_HOST?.trim() || "127.0.0.1",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @file logger.ts
|
||||
* @description Logger class for the MCP application, responsible for logging messages in JSON format to stderr with different log levels (debug, info, warn, error). The logger respects a minimum log level configuration and includes timestamps in ISO format. Each log entry is a single line of JSON containing the timestamp, log level, message, and optional metadata. This structured logging approach allows for easy parsing and analysis of logs. The Logger class provides methods for each log level and a private method to handle the actual writing of log entries to stderr.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../config/app-config.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `Logger` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **Logger**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { LogLevel } from "../config/app-config.js";
|
||||
|
||||
/** Numeric severity ranking; higher is more severe. */
|
||||
const LEVEL_ORDER: Record<LogLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
};
|
||||
|
||||
/**
|
||||
* Structured JSON logger for the MCP process. Every entry is one
|
||||
* newline-terminated JSON object written to **stderr**, never stdout — for
|
||||
* the stdio transport, stdout is the MCP JSON-RPC channel, so logging there
|
||||
* would corrupt the protocol stream. One instance is shared process-wide via
|
||||
* {@link ToolContext} and {@link DashboardApiClient}.
|
||||
*/
|
||||
export class Logger {
|
||||
/** @param minLevel Minimum severity written; lower calls are dropped.
|
||||
* Sourced from `AppConfig.logLevel` (`MCP_LOG_LEVEL`, default `"info"`). */
|
||||
constructor(private readonly minLevel: LogLevel) {}
|
||||
|
||||
/** Per-call tracing, e.g. tool invocation start/completion; silent unless
|
||||
* `MCP_LOG_LEVEL=debug`. */
|
||||
debug(message: string, meta?: Record<string, unknown>) {
|
||||
this.write("debug", message, meta);
|
||||
}
|
||||
|
||||
/** Default-visible lifecycle events (server started, new session opened). */
|
||||
info(message: string, meta?: Record<string, unknown>) {
|
||||
this.write("info", message, meta);
|
||||
}
|
||||
|
||||
/** Recoverable/transient issues, e.g. a retried dashboard API request. */
|
||||
warn(message: string, meta?: Record<string, unknown>) {
|
||||
this.write("warn", message, meta);
|
||||
}
|
||||
|
||||
/** Aborted operations, e.g. a thrown tool handler or unhandled rejection. */
|
||||
error(message: string, meta?: Record<string, unknown>) {
|
||||
this.write("error", message, meta);
|
||||
}
|
||||
|
||||
/** Writes one entry if `level` meets {@link minLevel}; `meta` is included
|
||||
* only when non-empty. */
|
||||
private write(level: LogLevel, message: string, meta?: Record<string, unknown>) {
|
||||
if (LEVEL_ORDER[level] < LEVEL_ORDER[this.minLevel]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const line = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
...(meta && Object.keys(meta).length > 0 ? { meta } : {}),
|
||||
});
|
||||
process.stderr.write(`${line}\n`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @file tool-registry.ts
|
||||
* @description Core functions for registering tools in the MCP server. This module defines the ToolRegistrar type, which is a function that can be used to register a tool with a name, description, input schema, and handler function. It also provides factory functions to create different types of registrars: one that registers tools directly with the MCP server and collects entries for REPL mode, and another that only collects entries without registering with the MCP server (for pure REPL mode). The registrars handle error logging and result formatting to ensure consistent behavior across different tool implementations.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./logger.js`
|
||||
* - `./tool-result.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ToolHandler` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ToolRegistrar` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ToolEntry` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `createToolRegistrar` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `createDualRegistrar` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `createCollectorRegistrar` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ToolHandler**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **ToolRegistrar**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **ToolEntry**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **createToolRegistrar**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **createDualRegistrar**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **createCollectorRegistrar**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import type { Logger } from "./logger.js";
|
||||
import { errorResult, jsonResult } from "./tool-result.js";
|
||||
|
||||
type GenericInput = Record<string, unknown>;
|
||||
|
||||
/** Signature every domain tool handler implements. Receives the
|
||||
* SDK-validated argument bag and returns raw JSON data — handlers do not
|
||||
* wrap results or catch their own errors; the registrar does that. */
|
||||
export type ToolHandler = (args: GenericInput) => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Function shape `tools/domains/*.ts` calls once per tool with a
|
||||
* `dashboard_*` name, description, Zod input shape, and handler. Two
|
||||
* implementations are wired up in `index.ts`: {@link createToolRegistrar}
|
||||
* (stdio, per-session HTTP/SSE) and {@link createCollectorRegistrar} (REPL,
|
||||
* no server) — so the same domain-registration code runs unmodified across
|
||||
* transports. A third, {@link createDualRegistrar}, combines both but isn't
|
||||
* currently wired into any transport.
|
||||
*/
|
||||
export interface ToolRegistrar {
|
||||
(
|
||||
name: string,
|
||||
description: string,
|
||||
inputSchema: Record<string, z.ZodTypeAny>,
|
||||
handler: ToolHandler
|
||||
): void;
|
||||
}
|
||||
|
||||
/** Plain-data record of one registered tool, independent of the MCP SDK.
|
||||
* Consumed by `transports/tool-collector.ts`/`transports/repl.ts` to invoke
|
||||
* tools directly, bypassing the MCP protocol. */
|
||||
export interface ToolEntry {
|
||||
name: string;
|
||||
description: string;
|
||||
handler: ToolHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link ToolRegistrar} that registers each tool directly with a
|
||||
* live `McpServer`. Its handler wrapper is the one place that logs
|
||||
* `debug`-level start/completion (or `error` on failure), converts a
|
||||
* success into a `CallToolResult` via {@link jsonResult}, and catches any
|
||||
* thrown error — converting it via {@link errorResult} — so a failing call
|
||||
* always resolves rather than rejects the MCP request.
|
||||
*/
|
||||
export function createToolRegistrar(server: McpServer, logger: Logger): ToolRegistrar {
|
||||
return (name, description, inputSchema, handler) => {
|
||||
server.registerTool(name, { description, inputSchema }, async (args) => {
|
||||
try {
|
||||
logger.debug("Tool invocation started", { tool: name });
|
||||
const result = await handler(args as GenericInput);
|
||||
logger.debug("Tool invocation completed", { tool: name });
|
||||
return jsonResult(name, result);
|
||||
} catch (error) {
|
||||
logger.error("Tool invocation failed", {
|
||||
tool: name,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return errorResult(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrar that also collects tool entries for REPL mode. Delegates to
|
||||
* {@link createToolRegistrar} and additionally pushes a plain
|
||||
* {@link ToolEntry}, so one call would both register a tool AND make it
|
||||
* directly invokable. Not currently used — `index.ts` builds REPL entries
|
||||
* via {@link createCollectorRegistrar} instead.
|
||||
*/
|
||||
export function createDualRegistrar(
|
||||
server: McpServer,
|
||||
logger: Logger,
|
||||
collector: ToolEntry[]
|
||||
): ToolRegistrar {
|
||||
const mcpRegistrar = createToolRegistrar(server, logger);
|
||||
return (name, description, inputSchema, handler) => {
|
||||
mcpRegistrar(name, description, inputSchema, handler);
|
||||
collector.push({ name, description, handler });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrar that only collects (no MCP server, for pure REPL mode). Used by
|
||||
* `collectAllTools` to build the REPL tool list with no protocol overhead —
|
||||
* thrown errors propagate as real exceptions to the REPL's own try/catch.
|
||||
*/
|
||||
export function createCollectorRegistrar(collector: ToolEntry[]): ToolRegistrar {
|
||||
return (name, description, _inputSchema, handler) => {
|
||||
collector.push({ name, description, handler });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @file tool-result.ts
|
||||
* @description Utility functions for formatting tool results in the MCP server. This module provides helper functions to create standardized result objects for successful tool calls (jsonResult) and error cases (errorResult). The jsonResult function formats the output with a title and pretty-printed JSON payload, while the errorResult function handles both known API errors and generic errors, ensuring that error information is consistently structured for the MCP client to display. These utilities help maintain a clear contract for tool handlers when returning results or errors.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../clients/dashboard-api-client.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `jsonResult` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `errorResult` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **jsonResult**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **errorResult**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { ApiError } from "../clients/dashboard-api-client.js";
|
||||
|
||||
/**
|
||||
* Wraps a successful handler return value into the MCP `CallToolResult`
|
||||
* shape. Called only from {@link createToolRegistrar}'s handler wrapper.
|
||||
* The result is a single `text` block: the tool name as a title, then the
|
||||
* payload pretty-printed as JSON — a display convenience, not a
|
||||
* machine-readable envelope.
|
||||
*/
|
||||
export function jsonResult(title: string, payload: unknown): CallToolResult {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${title}\n\n${JSON.stringify(payload, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a thrown error into an `isError: true` `CallToolResult`, called
|
||||
* only from {@link createToolRegistrar}'s catch block so a failing tool
|
||||
* always resolves rather than rejects. An {@link ApiError} (raised by
|
||||
* {@link DashboardApiClient} for any non-2xx response, timeout, or network
|
||||
* failure) surfaces its own `code`/`status`/`details`; any other error
|
||||
* (including policy-guard failures) collapses to a generic `INTERNAL_ERROR`
|
||||
* with just the message.
|
||||
*/
|
||||
export function errorResult(error: unknown): CallToolResult {
|
||||
if (error instanceof ApiError) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{
|
||||
error: error.message,
|
||||
code: error.code ?? null,
|
||||
status: error.status ?? null,
|
||||
details: error.details ?? null,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{
|
||||
error: message,
|
||||
code: "INTERNAL_ERROR",
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description The main entry point for the MCP application, responsible for initializing the server, loading configuration, setting up logging, and starting the appropriate transport based on configuration or command-line arguments. The application supports multiple transport modes (stdio, http, repl) and includes graceful shutdown handling. It also collects tools and registers them with the server when using HTTP or REPL transports. The main function orchestrates the startup process and ensures that any unhandled errors are logged before exiting.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./clients/dashboard-api-client.js`
|
||||
* - `./config/app-config.js`
|
||||
* - `./core/logger.js`
|
||||
* - `./server.js`
|
||||
* - `./transports/http-server.js`
|
||||
* - `./transports/repl.js`
|
||||
* - `./transports/tool-collector.js`
|
||||
* - `./ui/banner.js`
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { DashboardApiClient } from "./clients/dashboard-api-client.js";
|
||||
import { loadConfig, type TransportMode } from "./config/app-config.js";
|
||||
import { Logger } from "./core/logger.js";
|
||||
import { buildServer } from "./server.js";
|
||||
import { startHttpServer } from "./transports/http-server.js";
|
||||
import { startRepl } from "./transports/repl.js";
|
||||
import { collectAllTools } from "./transports/tool-collector.js";
|
||||
import { printBanner, printServerInfo, printReady, printShutdown } from "./ui/banner.js";
|
||||
|
||||
/**
|
||||
* Determines the final {@link TransportMode}, letting CLI flags override the
|
||||
* `MCP_TRANSPORT` env value passed as `env`. Priority: explicit
|
||||
* `--transport=<mode>`, then bare `--repl`/`--http`, then `env`. An
|
||||
* unrecognized `--transport=` value falls through rather than throwing.
|
||||
*/
|
||||
function resolveTransport(env: TransportMode): TransportMode {
|
||||
const cliArg = process.argv.find((a) => a.startsWith("--transport="));
|
||||
if (cliArg) {
|
||||
const val = cliArg.split("=")[1]?.toLowerCase();
|
||||
if (val === "stdio" || val === "http" || val === "repl") return val;
|
||||
}
|
||||
if (process.argv.includes("--repl")) return "repl";
|
||||
if (process.argv.includes("--http")) return "http";
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process entry point. Loads config, resolves the transport, and starts one
|
||||
* of three modes: **stdio** (default) — one `McpServer` via
|
||||
* {@link buildServer} over `StdioServerTransport`, how an MCP host like
|
||||
* Claude Code talks to this process, no console UI since stdout is the
|
||||
* JSON-RPC channel; **http** — {@link startHttpServer} builds a fresh
|
||||
* `McpServer` per client session; **repl** — tags each
|
||||
* {@link collectAllTools} tool with its domain and hands off to
|
||||
* {@link startRepl}, which owns the lifecycle from there (this function
|
||||
* returns immediately, skipping the signal setup below).
|
||||
*
|
||||
* For stdio/http, installs `SIGINT`/`SIGTERM` handlers invoking the
|
||||
* transport's `shutdownFn`, plus `unhandledRejection`/`uncaughtException`
|
||||
* handlers logging via {@link Logger} — the latter sets `process.exitCode = 1`
|
||||
* without exiting immediately, letting in-flight work finish.
|
||||
*/
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
const transport = resolveTransport(config.transport);
|
||||
const logger = new Logger(config.logLevel);
|
||||
const api = new DashboardApiClient(config, logger);
|
||||
|
||||
let shutdownFn: (() => Promise<void>) | undefined;
|
||||
|
||||
// ── stdio mode (default, backward compatible) ───────────────
|
||||
if (transport === "stdio") {
|
||||
const server = buildServer(config, api, logger);
|
||||
const stdioTransport = new StdioServerTransport();
|
||||
|
||||
await server.connect(stdioTransport);
|
||||
|
||||
logger.info("Agent Dashboard MCP server started", {
|
||||
serverName: config.serverName,
|
||||
serverVersion: config.serverVersion,
|
||||
dashboardBaseUrl: config.dashboardBaseUrl.toString(),
|
||||
allowMutations: config.allowMutations,
|
||||
allowDestructive: config.allowDestructive,
|
||||
transport: "stdio",
|
||||
});
|
||||
|
||||
shutdownFn = async () => {
|
||||
await stdioTransport.close?.();
|
||||
await server.close();
|
||||
};
|
||||
}
|
||||
|
||||
// ── HTTP mode (SSE + Streamable HTTP) ───────────────────────
|
||||
else if (transport === "http") {
|
||||
const toolEntries = collectAllTools(config, api, logger);
|
||||
const { shutdown } = await startHttpServer(
|
||||
config,
|
||||
() => {
|
||||
const s = buildServer(config, api, logger);
|
||||
return s;
|
||||
},
|
||||
logger,
|
||||
toolEntries.length
|
||||
);
|
||||
shutdownFn = shutdown;
|
||||
}
|
||||
|
||||
// ── REPL mode (interactive CLI) ─────────────────────────────
|
||||
else if (transport === "repl") {
|
||||
const TOOL_DOMAINS: Record<string, string> = {
|
||||
dashboard_health_check: "observability",
|
||||
dashboard_get_stats: "observability",
|
||||
dashboard_get_analytics: "observability",
|
||||
dashboard_get_system_info: "observability",
|
||||
dashboard_export_data: "observability",
|
||||
dashboard_get_operational_snapshot: "observability",
|
||||
dashboard_list_sessions: "sessions",
|
||||
dashboard_get_session: "sessions",
|
||||
dashboard_create_session: "sessions",
|
||||
dashboard_update_session: "sessions",
|
||||
dashboard_list_agents: "agents",
|
||||
dashboard_get_agent: "agents",
|
||||
dashboard_create_agent: "agents",
|
||||
dashboard_update_agent: "agents",
|
||||
dashboard_list_events: "events",
|
||||
dashboard_ingest_hook_event: "events",
|
||||
dashboard_get_pricing_rules: "pricing",
|
||||
dashboard_get_total_cost: "pricing",
|
||||
dashboard_get_session_cost: "pricing",
|
||||
dashboard_upsert_pricing_rule: "pricing",
|
||||
dashboard_delete_pricing_rule: "pricing",
|
||||
dashboard_reset_pricing_defaults: "pricing",
|
||||
dashboard_cleanup_data: "maintenance",
|
||||
dashboard_reimport_history: "maintenance",
|
||||
dashboard_reinstall_hooks: "maintenance",
|
||||
dashboard_clear_all_data: "maintenance",
|
||||
};
|
||||
|
||||
const toolEntries = collectAllTools(config, api, logger);
|
||||
const replTools = toolEntries.map((t) => ({
|
||||
...t,
|
||||
domain: TOOL_DOMAINS[t.name] ?? "unknown",
|
||||
}));
|
||||
await startRepl(config, api, logger, replTools);
|
||||
return; // REPL handles its own lifecycle
|
||||
}
|
||||
|
||||
// ── Graceful shutdown ───────────────────────────────────────
|
||||
const onSignal = async (signal: string) => {
|
||||
logger.info(`Received ${signal}, shutting down`);
|
||||
if (transport !== "stdio") printShutdown();
|
||||
await shutdownFn?.();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => onSignal("SIGINT"));
|
||||
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
logger.error("Unhandled promise rejection", {
|
||||
reason: reason instanceof Error ? reason.message : String(reason),
|
||||
});
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error("Uncaught exception", { error: error.message });
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
// Top-level guard for startup failures (e.g. loadConfig() rejecting an
|
||||
// invalid MCP_DASHBOARD_BASE_URL). Hand-writes one Logger.error-shaped JSON
|
||||
// line to stderr, since no Logger instance may exist yet, then exits non-zero.
|
||||
main().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "error",
|
||||
message: "Fatal startup error",
|
||||
meta: { error: message },
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @file tool-guards.ts
|
||||
* @description Guard functions to check if mutating and destructive tools are enabled based on the application configuration. These functions throw errors with informative messages if the required permissions are not granted, guiding developers to enable the necessary environment variables to use these tools. The assertMutationsEnabled function checks for general mutation permissions, while the assertDestructiveEnabled function checks for both mutation and destructive permissions, as well as validating a confirmation token to prevent accidental use of destructive tools.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../config/app-config.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `assertMutationsEnabled` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `assertDestructiveEnabled` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **assertMutationsEnabled**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **assertDestructiveEnabled**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { AppConfig } from "../config/app-config.js";
|
||||
|
||||
/**
|
||||
* Two policy tiers gate every write-capable tool, checked only here:
|
||||
* 1. **Mutations** (`config.allowMutations`, `MCP_DASHBOARD_ALLOW_MUTATIONS`)
|
||||
* — required by any create/update/reset/cleanup tool. Off by default, so
|
||||
* the server is read-only unless explicitly opted in.
|
||||
* 2. **Destructive** (`config.allowDestructive`, `MCP_DASHBOARD_ALLOW_DESTRUCTIVE`)
|
||||
* — a strictly higher tier on top of mutations, required only by
|
||||
* `dashboard_clear_all_data`.
|
||||
* Every write-tool handler calls one of these two functions first, before
|
||||
* any API call, so a disabled tier fails fast with no side effects.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Throws if mutating tools are disabled. Called first by every tool that
|
||||
* creates/updates/deletes/resets/cleans up dashboard state; read-only tools
|
||||
* (list/get/health/stats/analytics/export) never call this.
|
||||
* @throws {Error} naming `MCP_DASHBOARD_ALLOW_MUTATIONS=true` if
|
||||
* `config.allowMutations` is `false`.
|
||||
*/
|
||||
export function assertMutationsEnabled(config: AppConfig): void {
|
||||
if (!config.allowMutations) {
|
||||
throw new Error(
|
||||
"Mutating tools are disabled. Set MCP_DASHBOARD_ALLOW_MUTATIONS=true to enable them."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards the single most dangerous tool in the server —
|
||||
* `dashboard_clear_all_data`, which deletes every session/agent/event/
|
||||
* token-usage row. A three-part gate checked in order: mutations, then the
|
||||
* destructive flag, then the confirmation token, so the common
|
||||
* misconfiguration (mutations off) always surfaces the more general error
|
||||
* first.
|
||||
* @param confirmationToken Must exactly equal `"CLEAR_ALL_DATA"` — a
|
||||
* deliberate, unguessable-by-accident confirmation, not a secret.
|
||||
* @throws {Error} if mutations are disabled, `config.allowDestructive` is
|
||||
* `false`, or the token doesn't match exactly.
|
||||
*/
|
||||
export function assertDestructiveEnabled(config: AppConfig, confirmationToken: string): void {
|
||||
assertMutationsEnabled(config);
|
||||
if (!config.allowDestructive) {
|
||||
throw new Error(
|
||||
"Destructive tools are disabled. Set MCP_DASHBOARD_ALLOW_DESTRUCTIVE=true to enable them."
|
||||
);
|
||||
}
|
||||
if (confirmationToken !== "CLEAR_ALL_DATA") {
|
||||
throw new Error('Invalid confirmation_token. Expected exact value: "CLEAR_ALL_DATA".');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @file server.ts
|
||||
* @description Main entry point for building the MCP server. This module defines the buildServer function, which initializes a new MCP server instance with the provided configuration, API client, and logger. It also registers all tools by calling the registerAllTools function, which sets up the tool handlers for the server. The buildServer function returns the configured MCP server instance, ready to be started and handle incoming requests from the MCP client. This module serves as the central place for assembling the server components and ensuring that all necessary tools are registered before the server starts.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./config/app-config.js`
|
||||
* - `./clients/dashboard-api-client.js`
|
||||
* - `./core/logger.js`
|
||||
* - `./tools/index.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `buildServer` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **buildServer**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { AppConfig } from "./config/app-config.js";
|
||||
import { DashboardApiClient } from "./clients/dashboard-api-client.js";
|
||||
import { Logger } from "./core/logger.js";
|
||||
import { registerAllTools } from "./tools/index.js";
|
||||
|
||||
/**
|
||||
* Constructs one fully-configured `McpServer` with every `dashboard_*` tool
|
||||
* registered. A factory, not a singleton: stdio calls it once, while the
|
||||
* HTTP transport calls it once per new client session (Streamable HTTP or
|
||||
* legacy SSE), giving each session isolated server state while sharing the
|
||||
* same {@link AppConfig}/{@link DashboardApiClient}.
|
||||
* @returns A new `McpServer` with all six tool domains registered, ready to
|
||||
* `connect()` to a transport.
|
||||
*/
|
||||
export function buildServer(config: AppConfig, api: DashboardApiClient, logger: Logger): McpServer {
|
||||
const server = new McpServer({
|
||||
name: config.serverName,
|
||||
version: config.serverVersion,
|
||||
});
|
||||
|
||||
registerAllTools({
|
||||
server,
|
||||
config,
|
||||
api,
|
||||
logger,
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* @file agent-tools.ts
|
||||
* @description Defines and registers tools for managing agents in the dashboard, including listing agents with filters, retrieving agent details, creating new agents, and updating existing agents. Each tool includes input validation using Zod schemas and interacts with the dashboard API to perform the necessary operations. The tools also check for mutation permissions before allowing changes to agent data, ensuring that the application configuration is respected.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../core/tool-registry.js`
|
||||
* - `../../policy/tool-guards.js`
|
||||
* - `../schemas.js`
|
||||
* - `../../types/tool-context.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `registerAgentTools` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **registerAgentTools**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { z } from "zod";
|
||||
import { createToolRegistrar } from "../../core/tool-registry.js";
|
||||
import { assertMutationsEnabled } from "../../policy/tool-guards.js";
|
||||
import { AgentStatusSchema, JsonObjectSchema } from "../schemas.js";
|
||||
import type { ToolContext } from "../../types/tool-context.js";
|
||||
|
||||
/**
|
||||
* Registers the four agent-management tools backing `/api/agents/*`. List/
|
||||
* get are unconditional reads; create/update call
|
||||
* {@link assertMutationsEnabled} first. Agents mirror Claude Code's own
|
||||
* main-agent/subagent model: one main agent plus zero or more subagents
|
||||
* (`type: "subagent"`, optional `subagent_type`, linked via `parent_agent_id`).
|
||||
*/
|
||||
export function registerAgentTools(context: ToolContext): void {
|
||||
const { api, logger, server, config } = context;
|
||||
const register = createToolRegistrar(server, logger);
|
||||
|
||||
// Policy: none. Input: limit (1-500, default 50), offset (default 0),
|
||||
// status/session_id (optional). Calls GET /api/agents?... — the dashboard
|
||||
// honors only ONE of status/session_id per call (session_id wins,
|
||||
// ignoring limit/offset), so passing both doesn't intersect-filter.
|
||||
// Output: { agents, limit, offset }, each agent's own cost attached (from
|
||||
// its metadata token buckets, not its session's total).
|
||||
register(
|
||||
"dashboard_list_agents",
|
||||
"List agents with optional status/session filters and pagination.",
|
||||
{
|
||||
limit: z.number().int().min(1).max(500).optional(),
|
||||
offset: z.number().int().min(0).max(100_000).optional(),
|
||||
status: AgentStatusSchema.optional(),
|
||||
session_id: z.string().min(1).max(256).optional(),
|
||||
},
|
||||
async (args) => {
|
||||
const limit = (args.limit as number | undefined) ?? 50;
|
||||
const offset = (args.offset as number | undefined) ?? 0;
|
||||
return api.get("/api/agents", {
|
||||
query: {
|
||||
limit,
|
||||
offset,
|
||||
status: args.status as string | undefined,
|
||||
session_id: args.session_id as string | undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Policy: none. Input: agent_id (required). Calls GET /api/agents/:id.
|
||||
// Output: { agent } — 404s (ApiError, NOT_FOUND) if missing; unlike
|
||||
// dashboard_list_agents, no per-agent cost is attached.
|
||||
register(
|
||||
"dashboard_get_agent",
|
||||
"Get a single agent by ID.",
|
||||
{
|
||||
agent_id: z.string().min(1).max(256),
|
||||
},
|
||||
async (args) => {
|
||||
const agentId = args.agent_id as string;
|
||||
return api.get(`/api/agents/${encodeURIComponent(agentId)}`);
|
||||
}
|
||||
);
|
||||
|
||||
// Policy: MUTATIONS required. Input: id/session_id/name (required); type
|
||||
// (default "main"), subagent_type, status (default "waiting"), task,
|
||||
// parent_agent_id, metadata (all optional). Calls POST /api/agents.
|
||||
// Output: { agent, created } — an existing id returns as-is (created: false).
|
||||
register(
|
||||
"dashboard_create_agent",
|
||||
"Create a new agent in a session.",
|
||||
{
|
||||
id: z.string().min(1).max(256),
|
||||
session_id: z.string().min(1).max(256),
|
||||
name: z.string().min(1).max(500),
|
||||
type: z.enum(["main", "subagent"]).optional(),
|
||||
subagent_type: z.string().max(128).optional(),
|
||||
status: AgentStatusSchema.optional(),
|
||||
task: z.string().max(5000).optional(),
|
||||
parent_agent_id: z.string().max(256).optional(),
|
||||
metadata: JsonObjectSchema.optional(),
|
||||
},
|
||||
async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/agents", {
|
||||
body: {
|
||||
id: args.id,
|
||||
session_id: args.session_id,
|
||||
name: args.name,
|
||||
type: args.type,
|
||||
subagent_type: args.subagent_type,
|
||||
status: args.status,
|
||||
task: args.task,
|
||||
parent_agent_id: args.parent_agent_id,
|
||||
metadata: args.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Policy: MUTATIONS required. Input: agent_id (required);
|
||||
// name/status/task/current_tool/ended_at/metadata optional — current_tool
|
||||
// is nullable (explicitly clearable) and preserved when omitted entirely.
|
||||
// Calls PATCH /api/agents/:id. Output: { agent } — 404s if missing.
|
||||
register(
|
||||
"dashboard_update_agent",
|
||||
"Update an existing agent's lifecycle state and metadata.",
|
||||
{
|
||||
agent_id: z.string().min(1).max(256),
|
||||
name: z.string().max(500).optional(),
|
||||
status: AgentStatusSchema.optional(),
|
||||
task: z.string().max(5000).optional(),
|
||||
current_tool: z.string().max(256).nullable().optional(),
|
||||
ended_at: z.string().datetime().optional(),
|
||||
metadata: JsonObjectSchema.optional(),
|
||||
},
|
||||
async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
const agentId = args.agent_id as string;
|
||||
return api.patch(`/api/agents/${encodeURIComponent(agentId)}`, {
|
||||
body: {
|
||||
name: args.name,
|
||||
status: args.status,
|
||||
task: args.task,
|
||||
current_tool: args.current_tool,
|
||||
ended_at: args.ended_at,
|
||||
metadata: args.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @file event-tools.ts
|
||||
* @description Defines tools related to event management in the dashboard, including listing events with optional filters and ingesting hook events from Claude Code. The tools are registered with the tool registry and include input validation using Zod schemas. The event listing tool supports pagination and session filtering, while the hook event ingestion tool allows for adding new events into the dashboard pipeline, with a guard to ensure that mutations are enabled in the configuration.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../core/tool-registry.js`
|
||||
* - `../../policy/tool-guards.js`
|
||||
* - `../schemas.js`
|
||||
* - `../../types/tool-context.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `registerEventTools` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **registerEventTools**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { z } from "zod";
|
||||
import { createToolRegistrar } from "../../core/tool-registry.js";
|
||||
import { assertMutationsEnabled } from "../../policy/tool-guards.js";
|
||||
import { HookTypeSchema, JsonObjectSchema } from "../schemas.js";
|
||||
import type { ToolContext } from "../../types/tool-context.js";
|
||||
|
||||
/**
|
||||
* Registers the two event-related tools: a read-only list and a mutation
|
||||
* that feeds the same ingestion pipeline the installed Claude Code hooks
|
||||
* use (`scripts/hook-handler.js` → `POST /api/hooks/event`) — the one domain
|
||||
* where a tool can inject data into the dashboard's real-time pipeline
|
||||
* (websocket broadcast + alert evaluation), useful for testing hook
|
||||
* behavior without a live Claude Code session.
|
||||
*/
|
||||
export function registerEventTools(context: ToolContext): void {
|
||||
const { api, logger, server, config } = context;
|
||||
const register = createToolRegistrar(server, logger);
|
||||
|
||||
// Policy: none. Input: limit (1-200, default 50), offset (default 0),
|
||||
// session_id (optional). Calls GET /api/events?limit&offset&session_id.
|
||||
// Output: paginated event rows, most recent first.
|
||||
register(
|
||||
"dashboard_list_events",
|
||||
"List events with optional session filter and pagination.",
|
||||
{
|
||||
limit: z.number().int().min(1).max(200).optional(),
|
||||
offset: z.number().int().min(0).max(100_000).optional(),
|
||||
session_id: z.string().min(1).max(256).optional(),
|
||||
},
|
||||
async (args) => {
|
||||
const limit = (args.limit as number | undefined) ?? 50;
|
||||
const offset = (args.offset as number | undefined) ?? 0;
|
||||
return api.get("/api/events", {
|
||||
query: {
|
||||
limit,
|
||||
offset,
|
||||
session_id: args.session_id as string | undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Policy: MUTATIONS required. Input: hook_type (one of the seven Claude
|
||||
// Code hook names); data (arbitrary JSON — MUST include session_id, which
|
||||
// the dashboard uses to target the session). Calls POST /api/hooks/event,
|
||||
// the same endpoint scripts/hook-handler.js posts to on every real hook
|
||||
// firing. Output: { ok: true, event }. Side effects: bumps the session's
|
||||
// updated_at, broadcasts "new_event" over websocket, fire-and-forget
|
||||
// evaluates alert rules (failures swallowed), and — only for
|
||||
// "SubagentStop" with a transcript_path — scans that session's subagent
|
||||
// JSONL files for tool calls not yet recorded as events (the only path
|
||||
// that attributes subagent tool_use to the right agent_id, since those
|
||||
// never fire their own hooks). Throws (ApiError, MISSING_SESSION) if data
|
||||
// has no session_id.
|
||||
register(
|
||||
"dashboard_ingest_hook_event",
|
||||
"Ingest one Claude Code hook event into the dashboard pipeline.",
|
||||
{
|
||||
hook_type: HookTypeSchema,
|
||||
data: JsonObjectSchema,
|
||||
},
|
||||
async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/hooks/event", {
|
||||
body: {
|
||||
hook_type: args.hook_type,
|
||||
data: args.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @file maintenance-tools.ts
|
||||
* @description Defines a set of maintenance tools for the MCP dashboard, including functions to clean up stale sessions, re-import legacy data, reinstall hooks, and clear all data. These tools are registered with the MCP server and include appropriate guards to ensure that mutating and destructive actions are only performed when explicitly allowed in the configuration. The tools interact with the MCP server's API to perform the necessary maintenance tasks, providing a way for administrators to manage the dashboard's data and settings effectively.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../core/tool-registry.js`
|
||||
* - `../../policy/tool-guards.js`
|
||||
* - `../../types/tool-context.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `registerMaintenanceTools` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **registerMaintenanceTools**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { z } from "zod";
|
||||
import { createToolRegistrar } from "../../core/tool-registry.js";
|
||||
import { assertDestructiveEnabled, assertMutationsEnabled } from "../../policy/tool-guards.js";
|
||||
import type { ToolContext } from "../../types/tool-context.js";
|
||||
|
||||
/**
|
||||
* Registers four administrative tools against `/api/settings/*`. All four
|
||||
* require mutations; `dashboard_clear_all_data` additionally requires the
|
||||
* destructive tier plus an exact confirmation token, since it's the only
|
||||
* irreversible one (cleanup only touches stale/old rows; reimport and
|
||||
* reinstall-hooks are idempotent, repeatable operations).
|
||||
*/
|
||||
export function registerMaintenanceTools(context: ToolContext): void {
|
||||
const { api, logger, server, config } = context;
|
||||
const register = createToolRegistrar(server, logger);
|
||||
|
||||
// Policy: MUTATIONS required (checked before the "at least one field"
|
||||
// validation below). Input: abandon_hours (1 to 24*365) and/or purge_days
|
||||
// (1-3650) — at least one required. Calls POST /api/settings/cleanup.
|
||||
// abandon_hours marks "active" sessions with no recent events as
|
||||
// "abandoned" (completing lingering agents); purge_days permanently
|
||||
// deletes terminal sessions (+ agents/events) older than N days. Output:
|
||||
// { abandoned, purged_sessions, purged_events, purged_agents } counts.
|
||||
register(
|
||||
"dashboard_cleanup_data",
|
||||
"Maintenance: abandon stale sessions and/or purge old completed data.",
|
||||
{
|
||||
abandon_hours: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(24 * 365)
|
||||
.optional(),
|
||||
purge_days: z.number().int().min(1).max(3650).optional(),
|
||||
},
|
||||
async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
const abandonHours = args.abandon_hours as number | undefined;
|
||||
const purgeDays = args.purge_days as number | undefined;
|
||||
if (abandonHours === undefined && purgeDays === undefined) {
|
||||
throw new Error("At least one field is required: abandon_hours or purge_days.");
|
||||
}
|
||||
return api.post("/api/settings/cleanup", {
|
||||
body: {
|
||||
abandon_hours: abandonHours,
|
||||
purge_days: purgeDays,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Policy: MUTATIONS required. Calls POST /api/settings/reimport, invoking
|
||||
// scripts/import-history.js against ~/.claude session-history JSONL files
|
||||
// — useful for backfilling sessions that predate hook installation or
|
||||
// recovering after a reset. Output: { ok: true, ...result }. Throws
|
||||
// (ApiError, IMPORT_FAILED) if the import script itself throws.
|
||||
register(
|
||||
"dashboard_reimport_history",
|
||||
"Re-import legacy Claude sessions from ~/.claude into the local dashboard database.",
|
||||
{},
|
||||
async () => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/settings/reimport");
|
||||
}
|
||||
);
|
||||
|
||||
// Policy: MUTATIONS required. Calls POST /api/settings/reinstall-hooks,
|
||||
// invoking scripts/install-hooks.js to (re)write the seven hook entries
|
||||
// (PreToolUse/PostToolUse/Stop/SubagentStop/Notification/SessionStart/
|
||||
// SessionEnd) into ~/.claude/settings.json, overwriting any existing
|
||||
// config. Output: { ok, hooks } — same shape as dashboard_get_system_info.
|
||||
register(
|
||||
"dashboard_reinstall_hooks",
|
||||
"Reinstall Claude Code hooks in ~/.claude/settings.json.",
|
||||
{},
|
||||
async () => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/settings/reinstall-hooks");
|
||||
}
|
||||
);
|
||||
|
||||
// Policy: DESTRUCTIVE required — the strictest gate in the server. Input:
|
||||
// confirmation_token, must exactly equal "CLEAR_ALL_DATA". Calls
|
||||
// POST /api/settings/clear-data, irreversibly deleting every row from
|
||||
// sessions, agents, events, token_usage, alert_events, and
|
||||
// webhook_deliveries — but preserving alert rules, webhook targets, and
|
||||
// pricing rules (user configuration, not activity data). Output:
|
||||
// { ok: true, cleared } with pre-deletion row counts. No undo; the only
|
||||
// tool gated by MCP_DASHBOARD_ALLOW_DESTRUCTIVE.
|
||||
register(
|
||||
"dashboard_clear_all_data",
|
||||
"Delete all tracked sessions, agents, events, and token usage. Highly destructive.",
|
||||
{
|
||||
confirmation_token: z.string().min(1),
|
||||
},
|
||||
async (args) => {
|
||||
const confirmationToken = args.confirmation_token as string;
|
||||
assertDestructiveEnabled(config, confirmationToken);
|
||||
return api.post("/api/settings/clear-data");
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @file observability-tools.ts
|
||||
* @description Tool registration for observability-related tools in the MCP server. This module defines a set of tools that interact with the Agent Dashboard API to provide health checks, stats, analytics, system information, data export, and operational snapshots. These tools enable users to monitor and analyze the performance and usage of their agents and sessions through the dashboard. Each tool is registered with a name, description, input schema (if applicable), and an asynchronous handler function that makes API calls to retrieve the necessary data.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../types/tool-context.js`
|
||||
* - `../../core/tool-registry.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `registerObservabilityTools` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **registerObservabilityTools**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { z } from "zod";
|
||||
import type { ToolContext } from "../../types/tool-context.js";
|
||||
import { createToolRegistrar } from "../../core/tool-registry.js";
|
||||
|
||||
/**
|
||||
* Registers the six read-only observability tools. None call
|
||||
* {@link assertMutationsEnabled}/{@link assertDestructiveEnabled} — all are
|
||||
* plain GETs, always available regardless of policy flags.
|
||||
* `dashboard_get_operational_snapshot` is the only one fanning out to
|
||||
* multiple endpoints in parallel rather than proxying a single one.
|
||||
*/
|
||||
export function registerObservabilityTools(context: ToolContext): void {
|
||||
const { api, logger, server } = context;
|
||||
const register = createToolRegistrar(server, logger);
|
||||
|
||||
// Calls GET /api/health. Output: dashboard liveness payload — a fast
|
||||
// pre-flight check, since every other tool needs the dashboard running at
|
||||
// config.dashboardBaseUrl or it fails with an ApiError network/timeout.
|
||||
register(
|
||||
"dashboard_health_check",
|
||||
"Check health of the local Agent Dashboard API.",
|
||||
{},
|
||||
async () => api.get("/api/health")
|
||||
);
|
||||
|
||||
// Calls GET /api/stats. Output: session/agent counts by status,
|
||||
// events-today, and live websocket connection count.
|
||||
register(
|
||||
"dashboard_get_stats",
|
||||
"Get dashboard overview stats including session/agent counts and websocket connections.",
|
||||
{},
|
||||
async () => api.get("/api/stats")
|
||||
);
|
||||
|
||||
// Calls GET /api/analytics. Output: token totals/cost, per-tool usage
|
||||
// counts, daily event/session counts, agent type distribution, and
|
||||
// event-type breakdown — backs the dashboard's Analytics page.
|
||||
register(
|
||||
"dashboard_get_analytics",
|
||||
"Get analytics summary including token totals, usage trends, and distributions.",
|
||||
{},
|
||||
async () => api.get("/api/analytics")
|
||||
);
|
||||
|
||||
// Calls GET /api/settings/info. Output: SQLite path/size/counts/pragmas,
|
||||
// recent ingestion load (5/15/60 min), Claude Code hook install status,
|
||||
// and Node/OS process info (uptime, memory, cpu, ws connections).
|
||||
register(
|
||||
"dashboard_get_system_info",
|
||||
"Get system info, DB stats, and hook installation status.",
|
||||
{},
|
||||
async () => api.get("/api/settings/info")
|
||||
);
|
||||
|
||||
// Calls GET /api/settings/export. Output: the full dashboard dataset —
|
||||
// sessions, agents, events, token_usage, pricing rules — same payload the
|
||||
// UI's "Export Data" button downloads (its attachment header has no
|
||||
// effect on this client).
|
||||
register(
|
||||
"dashboard_export_data",
|
||||
"Export complete dashboard data payload (sessions, agents, events, tokens, pricing).",
|
||||
{},
|
||||
async () => api.get("/api/settings/export")
|
||||
);
|
||||
|
||||
// Input: three optional per-section limits, each defaulted below. Fans
|
||||
// out via Promise.all to GET /api/stats, /api/analytics, /api/events,
|
||||
// /api/sessions?status=active, and /api/agents queried twice
|
||||
// (status=working, status=connected — the dashboard filters one status
|
||||
// per call). Output: one combined { stats, analytics, recent_events,
|
||||
// active_sessions, active_agents: {working, connected}, generated_at }.
|
||||
register(
|
||||
"dashboard_get_operational_snapshot",
|
||||
"Get a high-signal operational snapshot combining stats, analytics, active sessions, active agents, and recent events.",
|
||||
{
|
||||
recent_events_limit: z.number().int().min(1).max(50).optional(),
|
||||
active_sessions_limit: z.number().int().min(1).max(100).optional(),
|
||||
active_agents_limit: z.number().int().min(1).max(200).optional(),
|
||||
},
|
||||
async (args) => {
|
||||
const eventsLimit = (args.recent_events_limit as number | undefined) ?? 20;
|
||||
const sessionsLimit = (args.active_sessions_limit as number | undefined) ?? 25;
|
||||
const agentsLimit = (args.active_agents_limit as number | undefined) ?? 100;
|
||||
|
||||
const [stats, analytics, recentEvents, activeSessions, workingAgents, connectedAgents] =
|
||||
await Promise.all([
|
||||
api.get("/api/stats"),
|
||||
api.get("/api/analytics"),
|
||||
api.get("/api/events", { query: { limit: eventsLimit, offset: 0 } }),
|
||||
api.get("/api/sessions", {
|
||||
query: { status: "active", limit: sessionsLimit, offset: 0 },
|
||||
}),
|
||||
api.get("/api/agents", {
|
||||
query: { status: "working", limit: agentsLimit, offset: 0 },
|
||||
}),
|
||||
api.get("/api/agents", {
|
||||
query: { status: "connected", limit: agentsLimit, offset: 0 },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
stats,
|
||||
analytics,
|
||||
recent_events: recentEvents,
|
||||
active_sessions: activeSessions,
|
||||
active_agents: {
|
||||
working: workingAgents,
|
||||
connected: connectedAgents,
|
||||
},
|
||||
generated_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @file pricing-tools.ts
|
||||
* @description Tool registration for pricing-related functionalities in the dashboard. This includes tools for retrieving pricing rules and calculating total costs based on usage. The tools interact with the backend API to fetch the necessary data and perform calculations as needed. The file also includes input validation using Zod schemas to ensure that the tool arguments are correctly formatted before processing. These tools are essential for providing users with insights into their costs and helping them manage their usage effectively.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../core/tool-registry.js`
|
||||
* - `../../policy/tool-guards.js`
|
||||
* - `../../types/tool-context.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `registerPricingTools` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **registerPricingTools**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { z } from "zod";
|
||||
import { createToolRegistrar } from "../../core/tool-registry.js";
|
||||
import { assertMutationsEnabled } from "../../policy/tool-guards.js";
|
||||
import type { ToolContext } from "../../types/tool-context.js";
|
||||
|
||||
/**
|
||||
* Registers six tools covering `/api/pricing/*` plus the pricing-adjacent
|
||||
* `/api/settings/reset-pricing`. Reads are always available; writes
|
||||
* (upsert/delete/reset) require {@link assertMutationsEnabled}. Costs are
|
||||
* priced as of the usage date (session start date), not today's rate, so
|
||||
* historical costs stay correct across a promotional-rate cutover.
|
||||
*/
|
||||
export function registerPricingTools(context: ToolContext): void {
|
||||
const { api, logger, server, config } = context;
|
||||
const register = createToolRegistrar(server, logger);
|
||||
|
||||
// Policy: none. Calls GET /api/pricing. Output: all model_pricing rows
|
||||
// (model_pattern, display_name, per-million-token rates).
|
||||
register(
|
||||
"dashboard_get_pricing_rules",
|
||||
"List all model pricing rules used for cost calculations.",
|
||||
{},
|
||||
async () => api.get("/api/pricing")
|
||||
);
|
||||
|
||||
// Policy: none. Calls GET /api/pricing/cost. Output: aggregate cost/token
|
||||
// totals across all sessions plus a per-day daily_costs breakdown, each
|
||||
// day priced at the rate effective on that date.
|
||||
register(
|
||||
"dashboard_get_total_cost",
|
||||
"Get total model usage cost across all tracked sessions.",
|
||||
{},
|
||||
async () => api.get("/api/pricing/cost")
|
||||
);
|
||||
|
||||
// Policy: none. Input: session_id (required). Calls
|
||||
// GET /api/pricing/cost/:sessionId. Output: cost/token breakdown for that
|
||||
// session, priced as of its start date.
|
||||
register(
|
||||
"dashboard_get_session_cost",
|
||||
"Get model usage cost breakdown for one session.",
|
||||
{
|
||||
session_id: z.string().min(1).max(256),
|
||||
},
|
||||
async (args) => {
|
||||
const sessionId = args.session_id as string;
|
||||
return api.get(`/api/pricing/cost/${encodeURIComponent(sessionId)}`);
|
||||
}
|
||||
);
|
||||
|
||||
// Policy: MUTATIONS required. Input: model_pattern + display_name
|
||||
// (required); input/output/cache_read/cache_write rates (optional,
|
||||
// defaulted to 0 here). Calls PUT /api/pricing — a true `INSERT ...
|
||||
// ON CONFLICT DO UPDATE` upsert (unlike sessions/agents' create-if-absent):
|
||||
// an existing rule is fully overwritten. CAUTION: cache_write_1h_per_mtok/
|
||||
// fast_input_per_mtok/fast_output_per_mtok aren't exposed here, so
|
||||
// upserting an existing rule silently zeroes those columns. Time-limited
|
||||
// intro_* rates are untouched (server only rewrites them when an intro_*
|
||||
// field is sent). Output: the upserted rule.
|
||||
register(
|
||||
"dashboard_upsert_pricing_rule",
|
||||
"Create or update a pricing rule.",
|
||||
{
|
||||
model_pattern: z.string().min(1).max(256),
|
||||
display_name: z.string().min(1).max(256),
|
||||
input_per_mtok: z.number().min(0).max(1_000_000).optional(),
|
||||
output_per_mtok: z.number().min(0).max(1_000_000).optional(),
|
||||
cache_read_per_mtok: z.number().min(0).max(1_000_000).optional(),
|
||||
cache_write_per_mtok: z.number().min(0).max(1_000_000).optional(),
|
||||
},
|
||||
async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.put("/api/pricing", {
|
||||
body: {
|
||||
model_pattern: args.model_pattern,
|
||||
display_name: args.display_name,
|
||||
input_per_mtok: args.input_per_mtok ?? 0,
|
||||
output_per_mtok: args.output_per_mtok ?? 0,
|
||||
cache_read_per_mtok: args.cache_read_per_mtok ?? 0,
|
||||
cache_write_per_mtok: args.cache_write_per_mtok ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Policy: MUTATIONS required. Input: model_pattern (exact match). Calls
|
||||
// DELETE /api/pricing/:model_pattern. Output: { ok: true }. Throws
|
||||
// (ApiError, NOT_FOUND) if no rule matches.
|
||||
register(
|
||||
"dashboard_delete_pricing_rule",
|
||||
"Delete one pricing rule by exact model_pattern.",
|
||||
{
|
||||
model_pattern: z.string().min(1).max(256),
|
||||
},
|
||||
async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.delete(`/api/pricing/${encodeURIComponent(args.model_pattern as string)}`);
|
||||
}
|
||||
);
|
||||
|
||||
// Policy: MUTATIONS required. Calls POST /api/settings/reset-pricing,
|
||||
// which deletes ALL rules (including custom ones) and reseeds the
|
||||
// built-in defaults, then re-applies any active intro-rate promos so they
|
||||
// aren't lost. Output: { ok: true, pricing: [...] } — the reseeded list.
|
||||
register(
|
||||
"dashboard_reset_pricing_defaults",
|
||||
"Reset pricing rules to dashboard defaults.",
|
||||
{},
|
||||
async () => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/settings/reset-pricing");
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @file remote-tools.ts
|
||||
* @description MCP tools for Remote Data Sources — list configured SSH sources
|
||||
* and trigger on-demand syncs so agents can operate remotes without the UI/CLI.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { createToolRegistrar } from "../../core/tool-registry.js";
|
||||
import { assertMutationsEnabled } from "../../policy/tool-guards.js";
|
||||
import type { ToolContext } from "../../types/tool-context.js";
|
||||
|
||||
/**
|
||||
* Registers remote-source tools against `/api/remote-sources/*`.
|
||||
* List is read-only; sync tools require the mutations policy gate.
|
||||
*/
|
||||
export function registerRemoteTools(context: ToolContext): void {
|
||||
const { api, logger, server, config } = context;
|
||||
const register = createToolRegistrar(server, logger);
|
||||
|
||||
register(
|
||||
"dashboard_list_remote_sources",
|
||||
"List configured Remote Data Sources (SSH machines) with status and last sync.",
|
||||
{},
|
||||
async () => api.get("/api/remote-sources")
|
||||
);
|
||||
|
||||
register(
|
||||
"dashboard_sync_remote_source",
|
||||
"Trigger an immediate SSH pull+import for one Remote Data Source by id.",
|
||||
{
|
||||
source_id: z.string().min(1).describe("Remote source id (src_…)"),
|
||||
},
|
||||
async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
const id = encodeURIComponent(args.source_id as string);
|
||||
return api.post(`/api/remote-sources/${id}/sync`);
|
||||
}
|
||||
);
|
||||
|
||||
register(
|
||||
"dashboard_sync_all_remote_sources",
|
||||
"Trigger an immediate SSH pull+import for every enabled Remote Data Source.",
|
||||
{},
|
||||
async () => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/remote-sources/sync-all");
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @file session-tools.ts
|
||||
* @description Defines and registers tools for managing sessions in the dashboard, including listing sessions with optional filters, retrieving session details, creating new sessions, and updating existing sessions. Each tool includes input validation using Zod schemas and interacts with the dashboard API to perform the necessary operations. The tools also check for mutation permissions before allowing changes to session data, ensuring that the application configuration is respected.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../../core/tool-registry.js`
|
||||
* - `../../policy/tool-guards.js`
|
||||
* - `../schemas.js`
|
||||
* - `../../types/tool-context.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `registerSessionTools` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **registerSessionTools**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { z } from "zod";
|
||||
import { createToolRegistrar } from "../../core/tool-registry.js";
|
||||
import { assertMutationsEnabled } from "../../policy/tool-guards.js";
|
||||
import { SessionStatusSchema, JsonObjectSchema } from "../schemas.js";
|
||||
import type { ToolContext } from "../../types/tool-context.js";
|
||||
|
||||
/**
|
||||
* Registers the four session-management tools backing `/api/sessions/*`.
|
||||
* List/get are read-only; create/update both call
|
||||
* {@link assertMutationsEnabled} first. None are gated by the
|
||||
* destructive-tools flag.
|
||||
*/
|
||||
export function registerSessionTools(context: ToolContext): void {
|
||||
const { api, logger, server, config } = context;
|
||||
const register = createToolRegistrar(server, logger);
|
||||
|
||||
// Policy: none. Input: limit (1-200, default 50), offset (default 0),
|
||||
// status (optional; omitted means all). Calls
|
||||
// GET /api/sessions?limit&offset&status. Output: { sessions, total, limit,
|
||||
// offset }.
|
||||
register(
|
||||
"dashboard_list_sessions",
|
||||
"List sessions with optional status filter and pagination.",
|
||||
{
|
||||
limit: z.number().int().min(1).max(200).optional(),
|
||||
offset: z.number().int().min(0).max(100_000).optional(),
|
||||
status: SessionStatusSchema.optional(),
|
||||
},
|
||||
async (args) => {
|
||||
const limit = (args.limit as number | undefined) ?? 50;
|
||||
const offset = (args.offset as number | undefined) ?? 0;
|
||||
const status = args.status as string | undefined;
|
||||
return api.get("/api/sessions", { query: { limit, offset, status } });
|
||||
}
|
||||
);
|
||||
|
||||
// Policy: none. Input: session_id (required). Calls
|
||||
// GET /api/sessions/:id. Output: { session, agents, events, workflows } —
|
||||
// agents carry their own cost (from agent.metadata token buckets),
|
||||
// workflows are any Workflow-tool runs launched in this session. 404s
|
||||
// (ApiError, NOT_FOUND) if missing.
|
||||
register(
|
||||
"dashboard_get_session",
|
||||
"Get one session with its full agents list and event timeline.",
|
||||
{
|
||||
session_id: z.string().min(1).max(256),
|
||||
},
|
||||
async (args) => {
|
||||
const sessionId = args.session_id as string;
|
||||
return api.get(`/api/sessions/${encodeURIComponent(sessionId)}`);
|
||||
}
|
||||
);
|
||||
|
||||
// Policy: MUTATIONS required. Input: id (required); name/cwd/model/
|
||||
// metadata (optional). Calls POST /api/sessions. Output: { session,
|
||||
// created } — an existing id returns as-is (created: false), matching how
|
||||
// the hook pipeline lazily creates sessions without erroring on a
|
||||
// duplicate id; a new session starts as "active".
|
||||
register(
|
||||
"dashboard_create_session",
|
||||
"Create a new session record if it does not already exist.",
|
||||
{
|
||||
id: z.string().min(1).max(256),
|
||||
name: z.string().max(500).optional(),
|
||||
cwd: z.string().max(2048).optional(),
|
||||
model: z.string().max(256).optional(),
|
||||
metadata: JsonObjectSchema.optional(),
|
||||
},
|
||||
async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/sessions", {
|
||||
body: {
|
||||
id: args.id,
|
||||
name: args.name,
|
||||
cwd: args.cwd,
|
||||
model: args.model,
|
||||
metadata: args.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Policy: MUTATIONS required. Input: session_id (required);
|
||||
// name/status/ended_at/metadata (optional; ended_at is ISO-8601). Calls
|
||||
// PATCH /api/sessions/:id. Output: the updated session record.
|
||||
register(
|
||||
"dashboard_update_session",
|
||||
"Update session metadata or lifecycle status.",
|
||||
{
|
||||
session_id: z.string().min(1).max(256),
|
||||
name: z.string().max(500).optional(),
|
||||
status: SessionStatusSchema.optional(),
|
||||
ended_at: z.string().datetime().optional(),
|
||||
metadata: JsonObjectSchema.optional(),
|
||||
},
|
||||
async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
const sessionId = args.session_id as string;
|
||||
return api.patch(`/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
body: {
|
||||
name: args.name,
|
||||
status: args.status,
|
||||
ended_at: args.ended_at,
|
||||
metadata: args.metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description Main entry point for registering all tools in the MCP application. This module imports and registers tools from various domains, including observability, session management, agent management, event handling, pricing, and maintenance. The registerAllTools function takes a ToolContext as an argument and calls the respective registration functions for each domain to ensure that all tools are properly set up and available for use within the application.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../types/tool-context.js`
|
||||
* - `./domains/observability-tools.js`
|
||||
* - `./domains/session-tools.js`
|
||||
* - `./domains/agent-tools.js`
|
||||
* - `./domains/event-tools.js`
|
||||
* - `./domains/pricing-tools.js`
|
||||
* - `./domains/maintenance-tools.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `registerAllTools` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **registerAllTools**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { ToolContext } from "../types/tool-context.js";
|
||||
import { registerObservabilityTools } from "./domains/observability-tools.js";
|
||||
import { registerSessionTools } from "./domains/session-tools.js";
|
||||
import { registerAgentTools } from "./domains/agent-tools.js";
|
||||
import { registerEventTools } from "./domains/event-tools.js";
|
||||
import { registerPricingTools } from "./domains/pricing-tools.js";
|
||||
import { registerMaintenanceTools } from "./domains/maintenance-tools.js";
|
||||
import { registerRemoteTools } from "./domains/remote-tools.js";
|
||||
|
||||
/**
|
||||
* Registers all 29 `dashboard_*` tools with the given {@link ToolContext} in
|
||||
* one call. `server.ts`'s `buildServer` calls this per `McpServer` instance;
|
||||
* `transports/tool-collector.ts`'s `collectAllTools` independently
|
||||
* re-implements the same registrations for REPL mode (no live server), so
|
||||
* the two files must be kept in sync by hand when a tool changes.
|
||||
*/
|
||||
export function registerAllTools(context: ToolContext): void {
|
||||
registerObservabilityTools(context);
|
||||
registerSessionTools(context);
|
||||
registerAgentTools(context);
|
||||
registerEventTools(context);
|
||||
registerPricingTools(context);
|
||||
registerMaintenanceTools(context);
|
||||
registerRemoteTools(context);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @file schemas.ts
|
||||
* @description Defines common Zod schemas used across different tools in the MCP application, including enumerations for session status, agent status, and hook types, as well as a generic JSON object schema. These schemas are used for input validation in various tools that manage sessions, agents, events, and hooks within the dashboard. By centralizing these schemas, we ensure consistency and reusability across the codebase.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `SessionStatusSchema` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `AgentStatusSchema` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `HookTypeSchema` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `JsonObjectSchema` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **SessionStatusSchema**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **AgentStatusSchema**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **HookTypeSchema**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **JsonObjectSchema**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
/** Session lifecycle states, mirroring the dashboard's `sessions.status`
|
||||
* column. Used by `dashboard_list_sessions`'s `status` filter and
|
||||
* `dashboard_update_session`'s `status` field. Only `"active"` sessions are
|
||||
* eligible for `dashboard_cleanup_data`'s `abandon_hours`; only terminal
|
||||
* states are eligible for its `purge_days`. */
|
||||
export const SessionStatusSchema = z.enum(["active", "completed", "error", "abandoned"]);
|
||||
|
||||
/** Agent lifecycle states, mirroring `agents.status`. Used by
|
||||
* `dashboard_list_agents`'s `status` filter and `dashboard_create_agent`/
|
||||
* `dashboard_update_agent`'s `status` field; new agents default to
|
||||
* `"waiting"` server-side when omitted. */
|
||||
export const AgentStatusSchema = z.enum(["working", "waiting", "completed", "error"]);
|
||||
|
||||
/** The seven Claude Code hook lifecycle events the dashboard's ingestion
|
||||
* pipeline understands, matching the hook names Claude Code invokes (wired
|
||||
* into `~/.claude/settings.json` by `scripts/install-hooks.js`). Used only
|
||||
* by `dashboard_ingest_hook_event`'s `hook_type` field — every real hook
|
||||
* firing posts one of these via `scripts/hook-handler.js`. */
|
||||
export const HookTypeSchema = z.enum([
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"SubagentStop",
|
||||
"Notification",
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
]);
|
||||
|
||||
/** Permissive arbitrary-JSON-object schema, used for the free-form
|
||||
* `metadata` field on session/agent tools and the hook `data` payload in
|
||||
* `dashboard_ingest_hook_event`, whose actual shape varies by `hook_type`
|
||||
* and is validated by the dashboard server itself, not this MCP layer. */
|
||||
export const JsonObjectSchema = z.record(z.unknown());
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* @file http-server.ts
|
||||
* @description Implements the HTTP server transport for the MCP server, supporting both the newer Streamable HTTP protocol and the legacy SSE-based protocol. The server handles incoming requests, manages active sessions, and routes messages to the appropriate transport handlers. It also includes a health check endpoint and integrates with the MCP server instance to facilitate communication with connected clients. The module provides a shutdown function to gracefully close all active transports and the HTTP server itself.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../config/app-config.js`
|
||||
* - `../core/logger.js`
|
||||
* - `../ui/banner.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `startHttpServer` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **startHttpServer**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import type { AppConfig } from "../config/app-config.js";
|
||||
import type { Logger } from "../core/logger.js";
|
||||
import { printBanner, printServerInfo, printReady, printShutdown } from "../ui/banner.js";
|
||||
import * as c from "../ui/colors.js";
|
||||
|
||||
/** One tracked client connection: the underlying MCP SDK transport plus
|
||||
* which protocol it speaks, so a request for a known session id can be
|
||||
* rejected if it mismatches the protocol that session was initialized with. */
|
||||
interface TransportEntry {
|
||||
transport: Transport;
|
||||
type: "streamable" | "sse";
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the HTTP transport, exposing the current Streamable HTTP protocol
|
||||
* (2025-11-25) and the legacy HTTP+SSE protocol (2024-11-05) side by side on
|
||||
* one Express app. Unlike stdio (one `McpServer` for the whole process),
|
||||
* **every new client session gets its own freshly-built `McpServer`** via
|
||||
* `buildServerFn`, isolated from other sessions but sharing the same
|
||||
* {@link AppConfig}/`DashboardApiClient`.
|
||||
*
|
||||
* Endpoints:
|
||||
* - `GET /health` — liveness/uptime/session-count probe for this MCP
|
||||
* process, distinct from `dashboard_health_check` (which checks the
|
||||
* dashboard itself).
|
||||
* - `ALL /mcp` — Streamable HTTP: a POST `initialize` with no
|
||||
* `mcp-session-id` starts a new session; later requests must carry that
|
||||
* header and route to the matching transport, rejected with a JSON-RPC
|
||||
* `-32000` error on a protocol mismatch.
|
||||
* - `GET /sse` — legacy SSE: a long-lived stream, one `SSEServerTransport` +
|
||||
* `McpServer` pair per connection.
|
||||
* - `POST /messages?sessionId=...` — legacy SSE's client-to-server companion
|
||||
* endpoint (SSE itself is server-to-client only).
|
||||
*
|
||||
* On successful bind, prints the banner/info panel/endpoint table to
|
||||
* stdout — this transport owns stdout, unlike stdio's protocol stream.
|
||||
* @returns The Express `app` and a `shutdown` closing every tracked
|
||||
* transport before the HTTP server itself.
|
||||
*/
|
||||
export async function startHttpServer(
|
||||
config: AppConfig,
|
||||
buildServerFn: () => McpServer,
|
||||
logger: Logger,
|
||||
toolCount: number
|
||||
): Promise<{ app: Express; shutdown: () => Promise<void> }> {
|
||||
const app = createMcpExpressApp({ host: config.httpHost });
|
||||
const transports = new Map<string, TransportEntry>();
|
||||
|
||||
// ── Health endpoint ───────────────────────────────────────────
|
||||
app.get("/health", (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
server: config.serverName,
|
||||
version: config.serverVersion,
|
||||
transport: "http",
|
||||
uptime: process.uptime(),
|
||||
activeSessions: transports.size,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Streamable HTTP (protocol version 2025-11-25) ─────────────
|
||||
app.all("/mcp", async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
||||
|
||||
try {
|
||||
if (sessionId && transports.has(sessionId)) {
|
||||
const entry = transports.get(sessionId)!;
|
||||
if (entry.type !== "streamable") {
|
||||
res.status(400).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32000, message: "Session uses a different transport protocol" },
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await (entry.transport as StreamableHTTPServerTransport).handleRequest(req, res, req.body);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && isInitializeRequest(req.body)) {
|
||||
logger.info("New Streamable HTTP session");
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
const sid = (transport as unknown as { sessionId?: string }).sessionId;
|
||||
if (sid) transports.delete(sid);
|
||||
logger.debug("Streamable HTTP session closed", { sessionId: sid });
|
||||
};
|
||||
|
||||
const server = buildServerFn();
|
||||
await server.connect(transport);
|
||||
|
||||
const sid = (transport as unknown as { sessionId?: string }).sessionId ?? randomUUID();
|
||||
transports.set(sid, { transport, type: "streamable" });
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(400).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32000, message: "Bad Request: No valid session or initialization" },
|
||||
id: null,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Streamable HTTP error", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32603, message: "Internal server error" },
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Legacy SSE transport (protocol version 2024-11-05) ────────
|
||||
app.get("/sse", async (_req: Request, res: Response) => {
|
||||
logger.info("New SSE session");
|
||||
const transport = new SSEServerTransport("/messages", res);
|
||||
transports.set(transport.sessionId, { transport, type: "sse" });
|
||||
|
||||
res.on("close", () => {
|
||||
transports.delete(transport.sessionId);
|
||||
logger.debug("SSE session closed", { sessionId: transport.sessionId });
|
||||
});
|
||||
|
||||
const server = buildServerFn();
|
||||
await server.connect(transport);
|
||||
});
|
||||
|
||||
app.post("/messages", async (req: Request, res: Response) => {
|
||||
const sessionId = req.query.sessionId as string | undefined;
|
||||
if (!sessionId || !transports.has(sessionId)) {
|
||||
res.status(400).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32000, message: "No transport found for session" },
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = transports.get(sessionId)!;
|
||||
if (entry.type !== "sse") {
|
||||
res.status(400).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32000, message: "Session uses a different transport protocol" },
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await (entry.transport as SSEServerTransport).handlePostMessage(req, res, req.body);
|
||||
});
|
||||
|
||||
// ── Start listening ───────────────────────────────────────────
|
||||
printBanner();
|
||||
printServerInfo({
|
||||
transport: "http (sse + streamable)",
|
||||
version: config.serverVersion,
|
||||
dashboard: config.dashboardBaseUrl.toString(),
|
||||
port: config.httpPort,
|
||||
mutations: config.allowMutations,
|
||||
destructive: config.allowDestructive,
|
||||
tools: toolCount,
|
||||
});
|
||||
|
||||
const httpServer = await new Promise<ReturnType<Express["listen"]>>((resolve, reject) => {
|
||||
const srv = app.listen(config.httpPort, config.httpHost, () => resolve(srv));
|
||||
srv.on("error", reject);
|
||||
});
|
||||
|
||||
const endpoints = [
|
||||
["Streamable HTTP", `http://${config.httpHost}:${config.httpPort}/mcp`, "POST/GET/DELETE"],
|
||||
["Legacy SSE", `http://${config.httpHost}:${config.httpPort}/sse`, "GET"],
|
||||
["Legacy Messages", `http://${config.httpHost}:${config.httpPort}/messages`, "POST"],
|
||||
["Health", `http://${config.httpHost}:${config.httpPort}/health`, "GET"],
|
||||
];
|
||||
|
||||
process.stdout.write(` ${c.bold(c.brightCyan("◆"))} ${c.bold(c.brightWhite("Endpoints"))}\n`);
|
||||
for (const [name, url, methods] of endpoints) {
|
||||
process.stdout.write(
|
||||
` ${c.dim(c.cyan("→"))} ${c.label(name.padEnd(20))} ${c.green(url)} ${c.muted(`[${methods}]`)}\n`
|
||||
);
|
||||
}
|
||||
process.stdout.write("\n");
|
||||
|
||||
printReady("http");
|
||||
|
||||
// ── Shutdown ──────────────────────────────────────────────────
|
||||
const shutdown = async () => {
|
||||
printShutdown();
|
||||
const closePromises: Promise<void>[] = [];
|
||||
for (const [sid, entry] of transports) {
|
||||
logger.debug("Closing transport", { sessionId: sid });
|
||||
closePromises.push(
|
||||
entry.transport.close?.().catch((err: unknown) => {
|
||||
logger.error("Error closing transport", {
|
||||
sessionId: sid,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}) ?? Promise.resolve()
|
||||
);
|
||||
}
|
||||
await Promise.allSettled(closePromises);
|
||||
transports.clear();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.close(() => resolve());
|
||||
});
|
||||
logger.info("HTTP server stopped");
|
||||
};
|
||||
|
||||
return { app, shutdown };
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* @file repl.ts
|
||||
* @description Implements a REPL (Read-Eval-Print Loop) transport for the MCP server, allowing users to interact with the dashboard API and invoke registered tools directly from the command line. The REPL provides an interactive prompt with command history and tab completion for tool names and commands. It supports built-in commands for listing tools, showing configuration, and performing health checks, as well as invoking any registered tool with JSON or key=value arguments. The REPL is designed for ease of use and quick experimentation during development or debugging sessions.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../config/app-config.js`
|
||||
* - `../clients/dashboard-api-client.js`
|
||||
* - `../core/logger.js`
|
||||
* - `../ui/banner.js`
|
||||
* - `../ui/formatter.js`
|
||||
* - `../core/tool-registry.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `startRepl` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ReplToolCollector` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `createReplToolCollector` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **startRepl**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **ReplToolCollector**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **createReplToolCollector**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import * as readline from "node:readline";
|
||||
import type { AppConfig } from "../config/app-config.js";
|
||||
import type { DashboardApiClient } from "../clients/dashboard-api-client.js";
|
||||
import type { Logger } from "../core/logger.js";
|
||||
import { printBanner, printServerInfo, printShutdown } from "../ui/banner.js";
|
||||
import * as c from "../ui/colors.js";
|
||||
import {
|
||||
formatToolResult,
|
||||
formatToolError,
|
||||
table,
|
||||
sectionHeader,
|
||||
divider,
|
||||
badge,
|
||||
} from "../ui/formatter.js";
|
||||
import type { ToolHandler } from "../core/tool-registry.js";
|
||||
|
||||
/** A `ToolEntry` (`name`/`description`/`handler`) tagged with a `domain` for
|
||||
* REPL display/completion only — the domain has no effect on invocation. */
|
||||
interface ToolEntry {
|
||||
name: string;
|
||||
description: string;
|
||||
handler: ToolHandler;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
/** Static `dashboard_<tool> -> domain` lookup mirroring `tools/domains/*.ts`
|
||||
* module boundaries, kept literal since `collectAllTools` has no domain
|
||||
* concept. Must be updated by hand alongside `index.ts`'s identical copy. */
|
||||
const TOOL_DOMAINS: Record<string, string> = {
|
||||
dashboard_health_check: "observability",
|
||||
dashboard_get_stats: "observability",
|
||||
dashboard_get_analytics: "observability",
|
||||
dashboard_get_system_info: "observability",
|
||||
dashboard_export_data: "observability",
|
||||
dashboard_get_operational_snapshot: "observability",
|
||||
dashboard_list_sessions: "sessions",
|
||||
dashboard_get_session: "sessions",
|
||||
dashboard_create_session: "sessions",
|
||||
dashboard_update_session: "sessions",
|
||||
dashboard_list_agents: "agents",
|
||||
dashboard_get_agent: "agents",
|
||||
dashboard_create_agent: "agents",
|
||||
dashboard_update_agent: "agents",
|
||||
dashboard_list_events: "events",
|
||||
dashboard_ingest_hook_event: "events",
|
||||
dashboard_get_pricing_rules: "pricing",
|
||||
dashboard_get_total_cost: "pricing",
|
||||
dashboard_get_session_cost: "pricing",
|
||||
dashboard_upsert_pricing_rule: "pricing",
|
||||
dashboard_delete_pricing_rule: "pricing",
|
||||
dashboard_reset_pricing_defaults: "pricing",
|
||||
dashboard_cleanup_data: "maintenance",
|
||||
dashboard_reimport_history: "maintenance",
|
||||
dashboard_reinstall_hooks: "maintenance",
|
||||
dashboard_clear_all_data: "maintenance",
|
||||
};
|
||||
|
||||
const DOMAIN_COLORS: Record<string, (t: string) => string> = {
|
||||
observability: c.brightCyan,
|
||||
sessions: c.brightGreen,
|
||||
agents: c.brightMagenta,
|
||||
events: c.brightYellow,
|
||||
pricing: (t: string) => c.bold(c.yellow(t)),
|
||||
maintenance: c.brightRed,
|
||||
};
|
||||
|
||||
/** Renders a `[domain]` badge in that domain's color, or muted if unknown. */
|
||||
function domainBadge(domain: string): string {
|
||||
const colorFn = DOMAIN_COLORS[domain] ?? c.muted;
|
||||
return colorFn(`[${domain}]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the interactive REPL and owns the process lifecycle from here —
|
||||
* `index.ts` returns immediately, since `readline`'s `"close"` event is this
|
||||
* transport's shutdown path. Unlike stdio/http, it never constructs an
|
||||
* `McpServer`: `tools` (from `collectAllTools`) is a flat, directly-
|
||||
* invokable handler list, so typing a tool name calls its handler
|
||||
* in-process, subject to the same `AppConfig` policy flags.
|
||||
*/
|
||||
export async function startRepl(
|
||||
config: AppConfig,
|
||||
api: DashboardApiClient,
|
||||
logger: Logger,
|
||||
tools: ToolEntry[]
|
||||
): Promise<void> {
|
||||
printBanner();
|
||||
printServerInfo({
|
||||
transport: "repl (interactive)",
|
||||
version: config.serverVersion,
|
||||
dashboard: config.dashboardBaseUrl.toString(),
|
||||
mutations: config.allowMutations,
|
||||
destructive: config.allowDestructive,
|
||||
tools: tools.length,
|
||||
});
|
||||
|
||||
process.stdout.write(
|
||||
` ${c.muted("Type")} ${c.accent("help")} ${c.muted("for commands,")} ${c.accent("tools")} ${c.muted("to list available tools,")} ${c.accent("exit")} ${c.muted("to quit.")}\n\n`
|
||||
);
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
prompt: ` ${c.bold(c.brightCyan("mcp"))}${c.dim(c.cyan("›"))} `,
|
||||
completer: (line: string) => {
|
||||
const allCompletions = [
|
||||
...tools.map((t) => t.name),
|
||||
"help",
|
||||
"tools",
|
||||
"domains",
|
||||
"exit",
|
||||
"quit",
|
||||
"clear",
|
||||
"health",
|
||||
"stats",
|
||||
"status",
|
||||
"config",
|
||||
];
|
||||
const hits = allCompletions.filter((cmd) => cmd.startsWith(line.trim()));
|
||||
return [hits.length ? hits : allCompletions, line];
|
||||
},
|
||||
});
|
||||
|
||||
const toolMap = new Map<string, ToolEntry>();
|
||||
for (const t of tools) toolMap.set(t.name, t);
|
||||
|
||||
rl.prompt();
|
||||
|
||||
rl.on("line", async (line) => {
|
||||
const input = line.trim();
|
||||
if (!input) {
|
||||
rl.prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await handleCommand(input, config, api, tools, toolMap, logger);
|
||||
} catch (err) {
|
||||
process.stdout.write(
|
||||
` ${c.error("Error:")} ${err instanceof Error ? err.message : String(err)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
rl.prompt();
|
||||
});
|
||||
|
||||
rl.on("close", () => {
|
||||
printShutdown();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches one entered line to a built-in command, or to
|
||||
* {@link invokeToolByName} if it matches a known tool name. Built-ins always
|
||||
* take precedence. `health`/`stats`/`status` are shortcuts invoking
|
||||
* `dashboard_health_check`/`dashboard_get_stats`/
|
||||
* `dashboard_get_operational_snapshot` with no arguments.
|
||||
*/
|
||||
async function handleCommand(
|
||||
input: string,
|
||||
config: AppConfig,
|
||||
_api: DashboardApiClient,
|
||||
tools: ToolEntry[],
|
||||
toolMap: Map<string, ToolEntry>,
|
||||
logger: Logger
|
||||
): Promise<void> {
|
||||
const [command, ...rest] = input.split(/\s+/);
|
||||
const argsRaw = rest.join(" ").trim();
|
||||
|
||||
switch (command.toLowerCase()) {
|
||||
case "help":
|
||||
printHelp();
|
||||
return;
|
||||
|
||||
case "tools":
|
||||
printToolList(tools, argsRaw || undefined);
|
||||
return;
|
||||
|
||||
case "domains":
|
||||
printDomains(tools);
|
||||
return;
|
||||
|
||||
case "health":
|
||||
await invokeToolByName("dashboard_health_check", {}, toolMap, logger);
|
||||
return;
|
||||
|
||||
case "stats":
|
||||
await invokeToolByName("dashboard_get_stats", {}, toolMap, logger);
|
||||
return;
|
||||
|
||||
case "status":
|
||||
await invokeToolByName("dashboard_get_operational_snapshot", {}, toolMap, logger);
|
||||
return;
|
||||
|
||||
case "config":
|
||||
printConfig(config);
|
||||
return;
|
||||
|
||||
case "clear":
|
||||
process.stdout.write("\x1b[2J\x1b[0;0H");
|
||||
return;
|
||||
|
||||
case "exit":
|
||||
case "quit":
|
||||
case "q":
|
||||
printShutdown();
|
||||
process.exit(0);
|
||||
|
||||
default:
|
||||
if (toolMap.has(command)) {
|
||||
const args = parseArgs(argsRaw);
|
||||
await invokeToolByName(command, args, toolMap, logger);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
` ${c.warn("?")} Unknown command: ${c.bold(c.brightWhite(command))} ${c.muted("— type 'help' for available commands")}\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Invokes a tool handler by name directly (no MCP protocol), printing an
|
||||
* "Invoking..." line then the formatted result/error. This is the REPL's
|
||||
* own error boundary — a thrown error is caught/logged here, not converted
|
||||
* to a `CallToolResult`. Args pass through unvalidated (no Zod check). */
|
||||
async function invokeToolByName(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
toolMap: Map<string, ToolEntry>,
|
||||
logger: Logger
|
||||
): Promise<void> {
|
||||
const tool = toolMap.get(name);
|
||||
if (!tool) {
|
||||
process.stdout.write(` ${c.error("✘")} Tool not found: ${c.bold(name)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = tool.domain;
|
||||
process.stdout.write(
|
||||
` ${c.dim(c.cyan("⟳"))} ${c.muted("Invoking")} ${c.bold(c.brightWhite(name))} ${domainBadge(domain)}${Object.keys(args).length > 0 ? " " + c.muted(JSON.stringify(args)) : ""}\n`
|
||||
);
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
const result = await tool.handler(args);
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
process.stdout.write(formatToolResult(name, result, elapsed) + "\n\n");
|
||||
} catch (err) {
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.error("REPL tool invocation failed", { tool: name, error: msg });
|
||||
process.stdout.write(formatToolError(name, msg, elapsed) + "\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses REPL tool args as a JSON object literal, or (if that fails)
|
||||
* space-separated `key=value` pairs with `true`/`false`/numeric coercion.
|
||||
* Not schema-aware. Empty input returns `{}`. */
|
||||
function parseArgs(raw: string): Record<string, unknown> {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed;
|
||||
return {};
|
||||
} catch {
|
||||
// Try key=value pairs
|
||||
const args: Record<string, unknown> = {};
|
||||
const pairs = raw.match(/(\w+)=("(?:\\"|[^"])*"|\S+)/g);
|
||||
if (pairs) {
|
||||
for (const pair of pairs) {
|
||||
const eqIndex = pair.indexOf("=");
|
||||
const key = pair.slice(0, eqIndex);
|
||||
let value: unknown = pair.slice(eqIndex + 1);
|
||||
if (typeof value === "string" && value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (value === "true") value = true;
|
||||
else if (value === "false") value = false;
|
||||
else if (!isNaN(Number(value)) && value !== "") value = Number(value);
|
||||
args[key] = value;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
}
|
||||
|
||||
/** Prints the built-in command reference and example tool invocations. */
|
||||
function printHelp(): void {
|
||||
process.stdout.write(sectionHeader("Available Commands"));
|
||||
|
||||
const commands = [
|
||||
["help", "Show this help message"],
|
||||
["tools [domain]", "List tools (optionally filtered by domain)"],
|
||||
["domains", "List all tool domains"],
|
||||
["health", "Quick dashboard health check"],
|
||||
["stats", "Dashboard overview statistics"],
|
||||
["status", "Full operational snapshot"],
|
||||
["config", "Show current configuration"],
|
||||
["clear", "Clear the screen"],
|
||||
["exit", "Quit the REPL"],
|
||||
["<tool_name> [json]", "Invoke a tool with optional JSON args"],
|
||||
["<tool_name> k=v ...", "Invoke a tool with key=value args"],
|
||||
];
|
||||
|
||||
const maxCmd = Math.max(...commands.map(([cmd]) => cmd.length));
|
||||
for (const [cmd, desc] of commands) {
|
||||
process.stdout.write(` ${c.accent(cmd.padEnd(maxCmd + 2))} ${c.muted(desc)}\n`);
|
||||
}
|
||||
process.stdout.write("\n");
|
||||
|
||||
process.stdout.write(sectionHeader("Examples"));
|
||||
process.stdout.write(` ${c.green('dashboard_list_sessions {"limit": 5}')}\n`);
|
||||
process.stdout.write(` ${c.green("dashboard_get_session session_id=abc123")}\n`);
|
||||
process.stdout.write(` ${c.green("dashboard_list_agents status=working limit=10")}\n\n`);
|
||||
}
|
||||
|
||||
/** Prints a table of tools (name, domain, truncated description) for
|
||||
* `tools`/`tools <domain>` (case-insensitive domain match). */
|
||||
function printToolList(tools: ToolEntry[], domainFilter?: string): void {
|
||||
const filtered = domainFilter
|
||||
? tools.filter((t) => t.domain === domainFilter.toLowerCase())
|
||||
: tools;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
process.stdout.write(
|
||||
` ${c.warn("!")} No tools found${domainFilter ? ` for domain '${domainFilter}'` : ""}\n`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const title = domainFilter ? `Tools — ${domainFilter}` : `All Tools (${filtered.length})`;
|
||||
|
||||
process.stdout.write(sectionHeader(title));
|
||||
|
||||
const rows = filtered.map((t) => ({
|
||||
name: t.name,
|
||||
domain: t.domain,
|
||||
description: t.description.length > 50 ? t.description.slice(0, 47) + "..." : t.description,
|
||||
}));
|
||||
|
||||
process.stdout.write(
|
||||
table(
|
||||
[
|
||||
{ key: "name", label: "Tool", width: 38, color: c.brightWhite },
|
||||
{
|
||||
key: "domain",
|
||||
label: "Domain",
|
||||
width: 14,
|
||||
color: (t) => {
|
||||
const fn = DOMAIN_COLORS[t] ?? c.muted;
|
||||
return fn(t);
|
||||
},
|
||||
},
|
||||
{ key: "description", label: "Description", width: 52, color: c.muted },
|
||||
],
|
||||
rows
|
||||
) + "\n\n"
|
||||
);
|
||||
}
|
||||
|
||||
/** Prints tool counts per domain (sorted) for the `domains` command. */
|
||||
function printDomains(tools: ToolEntry[]): void {
|
||||
const domainCounts = new Map<string, number>();
|
||||
for (const t of tools) {
|
||||
domainCounts.set(t.domain, (domainCounts.get(t.domain) ?? 0) + 1);
|
||||
}
|
||||
|
||||
process.stdout.write(sectionHeader("Tool Domains"));
|
||||
for (const [domain, count] of [...domainCounts.entries()].sort()) {
|
||||
const colorFn = DOMAIN_COLORS[domain] ?? c.muted;
|
||||
process.stdout.write(
|
||||
` ${colorFn("●")} ${c.bold(c.brightWhite(domain.padEnd(18)))} ${c.muted(`${count} tools`)}\n`
|
||||
);
|
||||
}
|
||||
process.stdout.write(
|
||||
`\n ${c.muted("Use")} ${c.accent("tools <domain>")} ${c.muted("to filter by domain.")}\n\n`
|
||||
);
|
||||
}
|
||||
|
||||
/** Prints the resolved {@link AppConfig} for `config`, including the live
|
||||
* Mutations/Destructive policy state (warning color when enabled). */
|
||||
function printConfig(config: AppConfig): void {
|
||||
process.stdout.write(sectionHeader("Configuration"));
|
||||
const pairs: [string, string][] = [
|
||||
["Server Name", c.brightWhite(config.serverName)],
|
||||
["Version", c.brightCyan(config.serverVersion)],
|
||||
["Dashboard URL", c.green(config.dashboardBaseUrl.toString())],
|
||||
["Transport", c.accent(config.transport.toUpperCase())],
|
||||
["Timeout", c.muted(`${config.requestTimeoutMs}ms`)],
|
||||
["Retries", c.muted(String(config.retryCount))],
|
||||
["Retry Backoff", c.muted(`${config.retryBackoffMs}ms`)],
|
||||
["Mutations", config.allowMutations ? c.warn("ENABLED") : badge("disabled")],
|
||||
["Destructive", config.allowDestructive ? c.error("ENABLED") : badge("disabled")],
|
||||
["Log Level", c.muted(config.logLevel)],
|
||||
];
|
||||
|
||||
for (const [k, v] of pairs) {
|
||||
process.stdout.write(` ${c.label(k.padEnd(18))} ${v}\n`);
|
||||
}
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
|
||||
// ── Exported helper to collect tools from registration ────────
|
||||
|
||||
/** Registrar-shaped helper for building a domain-tagged {@link ToolEntry}
|
||||
* list directly. Not currently wired into REPL startup — `index.ts` instead
|
||||
* combines `collectAllTools` with its own copy of `TOOL_DOMAINS`. */
|
||||
export interface ReplToolCollector {
|
||||
tools: ToolEntry[];
|
||||
register: (name: string, description: string, handler: ToolHandler) => void;
|
||||
}
|
||||
|
||||
/** Constructs an empty {@link ReplToolCollector}, tagging each registered
|
||||
* tool via {@link TOOL_DOMAINS} (falling back to `"unknown"`). */
|
||||
export function createReplToolCollector(): ReplToolCollector {
|
||||
const tools: ToolEntry[] = [];
|
||||
return {
|
||||
tools,
|
||||
register(name: string, description: string, handler: ToolHandler) {
|
||||
const domain = TOOL_DOMAINS[name] ?? "unknown";
|
||||
tools.push({ name, description, handler, domain });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* @file tool-collector.ts
|
||||
* @description This module defines the collectAllTools function, which is responsible for collecting and registering all tool handlers available in the MCP application. The function takes the application configuration, a dashboard API client, and a logger as arguments, and returns an array of ToolEntry objects representing each registered tool. The tools cover various domains such as observability, session management, agent management, event handling, pricing, and maintenance. This collector is used in REPL mode to allow direct invocation of tools without requiring an MCP Server instance.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../config/app-config.js`
|
||||
* - `../clients/dashboard-api-client.js`
|
||||
* - `../core/logger.js`
|
||||
* - `../core/tool-registry.js`
|
||||
* - `../policy/tool-guards.js`
|
||||
* - `../tools/schemas.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `collectAllTools` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **collectAllTools**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { AppConfig } from "../config/app-config.js";
|
||||
import type { DashboardApiClient } from "../clients/dashboard-api-client.js";
|
||||
import type { Logger } from "../core/logger.js";
|
||||
import {
|
||||
type ToolEntry,
|
||||
createCollectorRegistrar,
|
||||
type ToolHandler,
|
||||
} from "../core/tool-registry.js";
|
||||
import { assertMutationsEnabled, assertDestructiveEnabled } from "../policy/tool-guards.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
SessionStatusSchema,
|
||||
AgentStatusSchema,
|
||||
HookTypeSchema,
|
||||
JsonObjectSchema,
|
||||
} from "../tools/schemas.js";
|
||||
|
||||
/**
|
||||
* Collect all tool handlers without requiring an MCP Server instance.
|
||||
* Used by REPL mode to invoke tools directly.
|
||||
*
|
||||
* A hand-maintained, server-less mirror of `tools/index.ts`'s
|
||||
* `registerAllTools`/`tools/domains/*.ts`: it re-declares the same 29
|
||||
* `dashboard_*` tools using {@link createCollectorRegistrar} instead of
|
||||
* {@link createToolRegistrar}, so no `McpServer` or MCP protocol overhead is
|
||||
* needed — the REPL calls handlers directly and renders results with its
|
||||
* own formatter. Since this duplicates rather than imports the domain
|
||||
* modules' definitions, a change to a tool's args/defaults/endpoint must be
|
||||
* mirrored here by hand. `index.ts`'s HTTP startup also calls this once,
|
||||
* purely for an accurate startup-banner tool count — each HTTP/SSE session
|
||||
* still gets its own protocol-registered tools via `buildServer`.
|
||||
* @param logger Unused here — {@link createCollectorRegistrar} doesn't wrap
|
||||
* handlers in logging, so errors propagate as real exceptions to the REPL.
|
||||
*/
|
||||
export function collectAllTools(
|
||||
config: AppConfig,
|
||||
api: DashboardApiClient,
|
||||
logger: Logger
|
||||
): ToolEntry[] {
|
||||
const tools: ToolEntry[] = [];
|
||||
const register = createCollectorRegistrar(tools);
|
||||
|
||||
// ── Observability ───────────────────────────────────────────
|
||||
register(
|
||||
"dashboard_health_check",
|
||||
"Check health of the local Agent Dashboard API.",
|
||||
{},
|
||||
async () => api.get("/api/health")
|
||||
);
|
||||
register("dashboard_get_stats", "Get dashboard overview stats.", {}, async () =>
|
||||
api.get("/api/stats")
|
||||
);
|
||||
register("dashboard_get_analytics", "Get analytics summary.", {}, async () =>
|
||||
api.get("/api/analytics")
|
||||
);
|
||||
register("dashboard_get_system_info", "Get system info, DB stats, hook status.", {}, async () =>
|
||||
api.get("/api/settings/info")
|
||||
);
|
||||
register("dashboard_export_data", "Export complete dashboard data payload.", {}, async () =>
|
||||
api.get("/api/settings/export")
|
||||
);
|
||||
register(
|
||||
"dashboard_get_operational_snapshot",
|
||||
"High-signal operational snapshot.",
|
||||
{
|
||||
recent_events_limit: z.number().int().min(1).max(50).optional(),
|
||||
active_sessions_limit: z.number().int().min(1).max(100).optional(),
|
||||
active_agents_limit: z.number().int().min(1).max(200).optional(),
|
||||
},
|
||||
async (args) => {
|
||||
const eventsLimit = (args.recent_events_limit as number | undefined) ?? 20;
|
||||
const sessionsLimit = (args.active_sessions_limit as number | undefined) ?? 25;
|
||||
const agentsLimit = (args.active_agents_limit as number | undefined) ?? 100;
|
||||
const [stats, analytics, recentEvents, activeSessions, workingAgents, connectedAgents] =
|
||||
await Promise.all([
|
||||
api.get("/api/stats"),
|
||||
api.get("/api/analytics"),
|
||||
api.get("/api/events", { query: { limit: eventsLimit, offset: 0 } }),
|
||||
api.get("/api/sessions", {
|
||||
query: { status: "active", limit: sessionsLimit, offset: 0 },
|
||||
}),
|
||||
api.get("/api/agents", { query: { status: "working", limit: agentsLimit, offset: 0 } }),
|
||||
api.get("/api/agents", { query: { status: "connected", limit: agentsLimit, offset: 0 } }),
|
||||
]);
|
||||
return {
|
||||
stats,
|
||||
analytics,
|
||||
recent_events: recentEvents,
|
||||
active_sessions: activeSessions,
|
||||
active_agents: { working: workingAgents, connected: connectedAgents },
|
||||
generated_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ── Sessions ────────────────────────────────────────────────
|
||||
register("dashboard_list_sessions", "List sessions with optional filter.", {}, async (args) => {
|
||||
return api.get("/api/sessions", {
|
||||
query: {
|
||||
limit: (args.limit as number) ?? 50,
|
||||
offset: (args.offset as number) ?? 0,
|
||||
status: args.status as string | undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
register("dashboard_get_session", "Get one session with agents and events.", {}, async (args) => {
|
||||
return api.get(`/api/sessions/${encodeURIComponent(args.session_id as string)}`);
|
||||
});
|
||||
register("dashboard_create_session", "Create a new session record.", {}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/sessions", {
|
||||
body: {
|
||||
id: args.id,
|
||||
name: args.name,
|
||||
cwd: args.cwd,
|
||||
model: args.model,
|
||||
metadata: args.metadata,
|
||||
},
|
||||
});
|
||||
});
|
||||
register("dashboard_update_session", "Update session metadata or status.", {}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.patch(`/api/sessions/${encodeURIComponent(args.session_id as string)}`, {
|
||||
body: {
|
||||
name: args.name,
|
||||
status: args.status,
|
||||
ended_at: args.ended_at,
|
||||
metadata: args.metadata,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agents ──────────────────────────────────────────────────
|
||||
register("dashboard_list_agents", "List agents with filters.", {}, async (args) => {
|
||||
return api.get("/api/agents", {
|
||||
query: {
|
||||
limit: (args.limit as number) ?? 50,
|
||||
offset: (args.offset as number) ?? 0,
|
||||
status: args.status as string | undefined,
|
||||
session_id: args.session_id as string | undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
register("dashboard_get_agent", "Get a single agent by ID.", {}, async (args) => {
|
||||
return api.get(`/api/agents/${encodeURIComponent(args.agent_id as string)}`);
|
||||
});
|
||||
register("dashboard_create_agent", "Create a new agent in a session.", {}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/agents", {
|
||||
body: {
|
||||
id: args.id,
|
||||
session_id: args.session_id,
|
||||
name: args.name,
|
||||
type: args.type,
|
||||
subagent_type: args.subagent_type,
|
||||
status: args.status,
|
||||
task: args.task,
|
||||
parent_agent_id: args.parent_agent_id,
|
||||
metadata: args.metadata,
|
||||
},
|
||||
});
|
||||
});
|
||||
register("dashboard_update_agent", "Update agent lifecycle state.", {}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.patch(`/api/agents/${encodeURIComponent(args.agent_id as string)}`, {
|
||||
body: {
|
||||
name: args.name,
|
||||
status: args.status,
|
||||
task: args.task,
|
||||
current_tool: args.current_tool,
|
||||
ended_at: args.ended_at,
|
||||
metadata: args.metadata,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ── Events ──────────────────────────────────────────────────
|
||||
register(
|
||||
"dashboard_list_events",
|
||||
"List events with optional session filter.",
|
||||
{},
|
||||
async (args) => {
|
||||
return api.get("/api/events", {
|
||||
query: {
|
||||
limit: (args.limit as number) ?? 50,
|
||||
offset: (args.offset as number) ?? 0,
|
||||
session_id: args.session_id as string | undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
register("dashboard_ingest_hook_event", "Ingest a Claude Code hook event.", {}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/hooks/event", { body: { hook_type: args.hook_type, data: args.data } });
|
||||
});
|
||||
|
||||
// ── Pricing ─────────────────────────────────────────────────
|
||||
register("dashboard_get_pricing_rules", "List all model pricing rules.", {}, async () =>
|
||||
api.get("/api/pricing")
|
||||
);
|
||||
register("dashboard_get_total_cost", "Get total usage cost.", {}, async () =>
|
||||
api.get("/api/pricing/cost")
|
||||
);
|
||||
register(
|
||||
"dashboard_get_session_cost",
|
||||
"Get cost breakdown for one session.",
|
||||
{},
|
||||
async (args) => {
|
||||
return api.get(`/api/pricing/cost/${encodeURIComponent(args.session_id as string)}`);
|
||||
}
|
||||
);
|
||||
register(
|
||||
"dashboard_upsert_pricing_rule",
|
||||
"Create or update a pricing rule.",
|
||||
{},
|
||||
async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.put("/api/pricing", {
|
||||
body: {
|
||||
model_pattern: args.model_pattern,
|
||||
display_name: args.display_name,
|
||||
input_per_mtok: args.input_per_mtok ?? 0,
|
||||
output_per_mtok: args.output_per_mtok ?? 0,
|
||||
cache_read_per_mtok: args.cache_read_per_mtok ?? 0,
|
||||
cache_write_per_mtok: args.cache_write_per_mtok ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
register("dashboard_delete_pricing_rule", "Delete one pricing rule.", {}, async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.delete(`/api/pricing/${encodeURIComponent(args.model_pattern as string)}`);
|
||||
});
|
||||
register("dashboard_reset_pricing_defaults", "Reset pricing rules to defaults.", {}, async () => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/settings/reset-pricing");
|
||||
});
|
||||
|
||||
// ── Maintenance ─────────────────────────────────────────────
|
||||
register(
|
||||
"dashboard_cleanup_data",
|
||||
"Abandon stale sessions or purge old data.",
|
||||
{},
|
||||
async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
const abandonHours = args.abandon_hours as number | undefined;
|
||||
const purgeDays = args.purge_days as number | undefined;
|
||||
if (!abandonHours && !purgeDays)
|
||||
throw new Error("At least one of abandon_hours or purge_days is required.");
|
||||
return api.post("/api/settings/cleanup", {
|
||||
body: { abandon_hours: abandonHours, purge_days: purgeDays },
|
||||
});
|
||||
}
|
||||
);
|
||||
register("dashboard_reimport_history", "Re-import legacy Claude sessions.", {}, async () => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/settings/reimport");
|
||||
});
|
||||
register("dashboard_reinstall_hooks", "Reinstall Claude Code hooks.", {}, async () => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/settings/reinstall-hooks");
|
||||
});
|
||||
register("dashboard_clear_all_data", "Delete all data. Highly destructive.", {}, async (args) => {
|
||||
assertDestructiveEnabled(config, args.confirmation_token as string);
|
||||
return api.post("/api/settings/clear-data");
|
||||
});
|
||||
|
||||
// ── Remote Data Sources ─────────────────────────────────────
|
||||
register(
|
||||
"dashboard_list_remote_sources",
|
||||
"List configured Remote Data Sources (SSH machines).",
|
||||
{},
|
||||
async () => api.get("/api/remote-sources")
|
||||
);
|
||||
register(
|
||||
"dashboard_sync_remote_source",
|
||||
"Trigger an immediate SSH pull+import for one Remote Data Source.",
|
||||
{
|
||||
source_id: z.string().min(1),
|
||||
},
|
||||
async (args) => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post(`/api/remote-sources/${encodeURIComponent(args.source_id as string)}/sync`);
|
||||
}
|
||||
);
|
||||
register(
|
||||
"dashboard_sync_all_remote_sources",
|
||||
"Trigger an immediate SSH pull+import for every enabled Remote Data Source.",
|
||||
{},
|
||||
async () => {
|
||||
assertMutationsEnabled(config);
|
||||
return api.post("/api/remote-sources/sync-all");
|
||||
}
|
||||
);
|
||||
|
||||
return tools;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @file tool-context.ts
|
||||
* @description Defines the ToolContext interface, which encapsulates the necessary context for tool handlers in the MCP application. This context includes references to the MCP server instance, application configuration, dashboard API client, and logger. The ToolContext is passed to tool registration functions to provide them with access to these resources when defining and implementing tools. This design promotes modularity and separation of concerns by centralizing shared dependencies in a single context object.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `../config/app-config.js`
|
||||
* - `../clients/dashboard-api-client.js`
|
||||
* - `../core/logger.js`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `ToolContext` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **ToolContext**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { AppConfig } from "../config/app-config.js";
|
||||
import type { DashboardApiClient } from "../clients/dashboard-api-client.js";
|
||||
import type { Logger } from "../core/logger.js";
|
||||
|
||||
/**
|
||||
* Shared dependency bundle injected into every `register*Tools` function
|
||||
* under `tools/domains/`. Adding a new dependency only requires updating
|
||||
* this interface and `server.ts` (the sole place that constructs it).
|
||||
*/
|
||||
export interface ToolContext {
|
||||
/** MCP server tool modules call `registerTool` on, via a {@link ToolRegistrar}. */
|
||||
server: McpServer;
|
||||
/** Resolved config — dashboard URL, timeouts/retries, mutation/destructive
|
||||
* policy flags checked by `policy/tool-guards.ts`. */
|
||||
config: AppConfig;
|
||||
/** HTTP client to the dashboard's `/api/*` Express API — the only way
|
||||
* tools read or write dashboard state. */
|
||||
api: DashboardApiClient;
|
||||
/** Shared structured JSON logger (stderr). */
|
||||
logger: Logger;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @file banner.ts
|
||||
* @description Console startup UI for the MCP server's non-stdio transports (HTTP and REPL):
|
||||
* the ASCII-art wordmark, a boxed server-info panel (version, transport, dashboard URL, port,
|
||||
* tool count, mutation/destructive policy state), a "ready" line, and a shutdown message. The
|
||||
* stdio transport never calls any of these, since stdout there is the MCP JSON-RPC channel.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `printBanner` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `printServerInfo` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `printReady` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `printShutdown` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **printBanner**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **printServerInfo**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **printReady**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **printShutdown**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import * as c from "./colors.js";
|
||||
|
||||
/** ASCII-art wordmark rendered by {@link printBanner} with a color gradient. */
|
||||
const BANNER = `
|
||||
$$\\ $$\\ $$$$$$\\ $$$$$$$\\ $$$$$$$$\\ $$\\
|
||||
$$$\\ $$$ |$$ __$$\\ $$ __$$\\ \\__$$ __| $$ |
|
||||
$$$$\\ $$$$ |$$ / \\__|$$ | $$ | $$ | $$$$$$\\ $$$$$$\\ $$ | $$$$$$$\\
|
||||
$$\\$$\\$$ $$ |$$ | $$$$$$$ | $$ |$$ __$$\\ $$ __$$\\ $$ |$$ _____|
|
||||
$$ \\$$$ $$ |$$ | $$ ____/ $$ |$$ / $$ |$$ / $$ |$$ |\\$$$$$$\\
|
||||
$$ |\\$ /$$ |$$ | $$\\ $$ | $$ |$$ | $$ |$$ | $$ |$$ | \\____$$\\
|
||||
$$ | \\_/ $$ |\\$$$$$$ |$$ | $$ |\\$$$$$$ |\\$$$$$$ |$$ |$$$$$$$ |
|
||||
\\__| \\__| \\______/ \\__| \\__| \\______/ \\______/ \\__|\\_______/ `;
|
||||
|
||||
/** Prints {@link BANNER} one line per gradient color (cyan to magenta).
|
||||
* Called at HTTP/REPL startup only. */
|
||||
export function printBanner(): void {
|
||||
const gradient = [c.brightCyan, c.cyan, c.brightBlue, c.blue, c.brightMagenta, c.magenta];
|
||||
const lines = BANNER.split("\n").filter((l) => l.length > 0);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const colorFn = gradient[Math.min(i, gradient.length - 1)];
|
||||
process.stdout.write(colorFn(lines[i]) + "\n");
|
||||
}
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
|
||||
/** Prints a boxed config summary beneath the banner, shared by HTTP (`port`
|
||||
* set) and REPL (`port` omitted). Mutations/Destructive rows mirror the
|
||||
* `policy/tool-guards.ts` flags, warning-colored when enabled. Ends with a
|
||||
* reminder that the dashboard must already be running at the printed URL. */
|
||||
export function printServerInfo(info: {
|
||||
transport: string;
|
||||
version: string;
|
||||
dashboard: string;
|
||||
port?: number;
|
||||
mutations: boolean;
|
||||
destructive: boolean;
|
||||
tools: number;
|
||||
}): void {
|
||||
const divider = c.dim(c.cyan("─".repeat(62)));
|
||||
const line = (label: string, value: string) =>
|
||||
` ${c.dim(c.cyan("│"))} ${c.label(label.padEnd(18))} ${value}`;
|
||||
|
||||
process.stdout.write(divider + "\n");
|
||||
process.stdout.write(
|
||||
` ${c.dim(c.cyan("│"))} ${c.bold(c.brightWhite("Agent Dashboard MCP Server"))}\n`
|
||||
);
|
||||
process.stdout.write(divider + "\n");
|
||||
process.stdout.write(line("Version", c.brightCyan(info.version)) + "\n");
|
||||
process.stdout.write(line("Transport", c.accent(info.transport.toUpperCase())) + "\n");
|
||||
process.stdout.write(line("Dashboard API", c.green(info.dashboard)) + "\n");
|
||||
if (info.port !== undefined) {
|
||||
process.stdout.write(line("HTTP Port", c.brightYellow(String(info.port))) + "\n");
|
||||
}
|
||||
process.stdout.write(line("Tools Registered", c.brightWhite(String(info.tools))) + "\n");
|
||||
process.stdout.write(
|
||||
line("Mutations", info.mutations ? c.warn("ENABLED") : c.success("disabled")) + "\n"
|
||||
);
|
||||
process.stdout.write(
|
||||
line("Destructive", info.destructive ? c.error("ENABLED") : c.success("disabled")) + "\n"
|
||||
);
|
||||
process.stdout.write(divider + "\n");
|
||||
process.stdout.write(
|
||||
` ${c.dim(c.cyan("│"))} ${c.warn("⚠")} ${c.dim("Dashboard must be running at the URL above.")}\n`
|
||||
);
|
||||
process.stdout.write(
|
||||
` ${c.dim(c.cyan("│"))} ${c.dim(" Start it first:")} ${c.brightWhite("npm run dev")} ${c.dim("or")} ${c.brightWhite("npm start")}\n`
|
||||
);
|
||||
process.stdout.write(divider + "\n\n");
|
||||
}
|
||||
|
||||
/** Prints "Server ready" once the HTTP server has bound to its port; not
|
||||
* used by the REPL transport. */
|
||||
export function printReady(transport: string): void {
|
||||
const icon = "✔";
|
||||
process.stdout.write(
|
||||
` ${c.success(icon)} ${c.bold(c.brightWhite("Server ready"))} ${c.muted(`(${transport})`)}\n\n`
|
||||
);
|
||||
}
|
||||
|
||||
/** Prints "Shutting down...". Called from HTTP/REPL shutdown paths and
|
||||
* `index.ts`'s SIGINT/SIGTERM handler; never from stdio. */
|
||||
export function printShutdown(): void {
|
||||
process.stdout.write(`\n ${c.warn("⏻")} ${c.bold(c.brightWhite("Shutting down..."))}\n`);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* @file colors.ts
|
||||
* @description Provides utility functions for applying ANSI color codes to text in the terminal. This module defines a set of functions for styling text with various colors and modifiers such as bold, italic, underline, and strikethrough. It also includes support for 256-color mode and a function to strip ANSI codes from text. The color functions are designed to be composable, allowing for easy combination of styles. The module checks for color support in the terminal environment and gracefully degrades if colors are not supported.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `bold` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `dim` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `italic` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `underline` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `strikethrough` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `black` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `red` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `green` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `yellow` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `blue` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `magenta` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `cyan` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `white` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `gray` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `brightRed` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `brightGreen` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `brightYellow` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `brightBlue` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `brightMagenta` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `brightCyan` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `brightWhite` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bgRed` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bgGreen` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bgYellow` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bgBlue` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bgMagenta` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bgCyan` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bgWhite` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bgGray` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `fg256` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `bg256` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `reset` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `stripAnsi` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `success` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `error` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `warn` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `info` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `muted` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `highlight` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `label` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - … plus 1 additional exports
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **bold**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **dim**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **italic**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **underline**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **strikethrough**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **black**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **red**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **green**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **yellow**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **blue**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **magenta**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **cyan**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **white**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **gray**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **brightRed**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **brightGreen**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **brightYellow**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **brightBlue**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **brightMagenta**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **brightCyan**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **brightWhite**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bgRed**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bgGreen**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bgYellow**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bgBlue**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bgMagenta**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bgCyan**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bgWhite**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bgGray**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **fg256**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **bg256**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **reset**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **stripAnsi**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **success**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **error**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **warn**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **info**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **muted**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **highlight**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **label**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **accent**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
/** Whether ANSI colors should be emitted: `NO_COLOR` always disables;
|
||||
* `FORCE_COLOR=0` disables, any other `FORCE_COLOR` enables regardless of
|
||||
* TTY; otherwise enabled only on an interactive stdout TTY. Computed once
|
||||
* at module load. */
|
||||
const isColorSupported =
|
||||
process.env.FORCE_COLOR !== "0" &&
|
||||
process.env.NO_COLOR === undefined &&
|
||||
(process.env.FORCE_COLOR !== undefined || (process.stdout.isTTY ?? false));
|
||||
|
||||
/** Builds a styling function wrapping text in ANSI open/close codes, or an
|
||||
* identity function when colors are unsupported — every color/modifier
|
||||
* below is built with this, so disabling color no-ops all of them at once. */
|
||||
function wrap(open: string, close: string): (text: string) => string {
|
||||
if (!isColorSupported) return (text) => text;
|
||||
return (text) => `\x1b[${open}m${text}\x1b[${close}m`;
|
||||
}
|
||||
|
||||
// Modifiers
|
||||
export const bold = wrap("1", "22");
|
||||
export const dim = wrap("2", "22");
|
||||
export const italic = wrap("3", "23");
|
||||
export const underline = wrap("4", "24");
|
||||
export const strikethrough = wrap("9", "29");
|
||||
|
||||
// Foreground colors
|
||||
export const black = wrap("30", "39");
|
||||
export const red = wrap("31", "39");
|
||||
export const green = wrap("32", "39");
|
||||
export const yellow = wrap("33", "39");
|
||||
export const blue = wrap("34", "39");
|
||||
export const magenta = wrap("35", "39");
|
||||
export const cyan = wrap("36", "39");
|
||||
export const white = wrap("37", "39");
|
||||
export const gray = wrap("90", "39");
|
||||
|
||||
// Bright foreground colors
|
||||
export const brightRed = wrap("91", "39");
|
||||
export const brightGreen = wrap("92", "39");
|
||||
export const brightYellow = wrap("93", "39");
|
||||
export const brightBlue = wrap("94", "39");
|
||||
export const brightMagenta = wrap("95", "39");
|
||||
export const brightCyan = wrap("96", "39");
|
||||
export const brightWhite = wrap("97", "39");
|
||||
|
||||
// Background colors
|
||||
export const bgRed = wrap("41", "49");
|
||||
export const bgGreen = wrap("42", "49");
|
||||
export const bgYellow = wrap("43", "49");
|
||||
export const bgBlue = wrap("44", "49");
|
||||
export const bgMagenta = wrap("45", "49");
|
||||
export const bgCyan = wrap("46", "49");
|
||||
export const bgWhite = wrap("47", "49");
|
||||
export const bgGray = wrap("100", "49");
|
||||
|
||||
// 256-color support
|
||||
|
||||
/** Foreground-color function for an xterm 256-color index; not currently
|
||||
* used by any composable style below. */
|
||||
export function fg256(code: number): (text: string) => string {
|
||||
if (!isColorSupported) return (text) => text;
|
||||
return (text) => `\x1b[38;5;${code}m${text}\x1b[39m`;
|
||||
}
|
||||
|
||||
/** Background-color function for an xterm 256-color index. */
|
||||
export function bg256(code: number): (text: string) => string {
|
||||
if (!isColorSupported) return (text) => text;
|
||||
return (text) => `\x1b[48;5;${code}m${text}\x1b[49m`;
|
||||
}
|
||||
|
||||
// Utility
|
||||
/** Raw ANSI "reset all styles" sequence, or `""` when colors are disabled. */
|
||||
export const reset = isColorSupported ? "\x1b[0m" : "";
|
||||
|
||||
/** Strips ANSI SGR sequences from `text`. Used throughout `ui/formatter.ts`
|
||||
* to measure/pad colored strings by visible length, not byte length. */
|
||||
export function stripAnsi(text: string): string {
|
||||
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
// Composable styles
|
||||
/** Semantic style aliases used throughout `ui/banner.ts`, `ui/formatter.ts`,
|
||||
* and `transports/repl.ts` so call sites express intent, not a specific color. */
|
||||
export const success = (t: string) => bold(green(t));
|
||||
export const error = (t: string) => bold(red(t));
|
||||
export const warn = (t: string) => bold(yellow(t));
|
||||
export const info = (t: string) => bold(cyan(t));
|
||||
export const muted = (t: string) => dim(gray(t));
|
||||
export const highlight = (t: string) => bold(brightMagenta(t));
|
||||
export const label = (t: string) => bold(brightWhite(t));
|
||||
export const accent = (t: string) => bold(brightCyan(t));
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* @file formatter.ts
|
||||
* @description A collection of utility functions for formatting console output in the MCP application. This includes functions for creating boxed sections, tables, status badges, formatted tool results, and key-value lists. The formatting is designed to be visually appealing and informative when printed to the terminal, using colors and styles to enhance readability. These utilities are used across various tools and components in the MCP application to maintain a consistent look and feel in the console output.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||||
*
|
||||
* ## Design constraints
|
||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||||
*
|
||||
* ## Remote data & SSH
|
||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Observability
|
||||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||||
*
|
||||
* ## Public surface
|
||||
* - `box` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `divider` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `Column` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `table` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `badge` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `formatToolResult` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `formatToolError` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `keyValue` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `sectionHeader` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `SPINNER_FRAMES` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `progressBar` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||||
*
|
||||
* ## Related docs
|
||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||||
* - `docs/API.md` — REST reference.
|
||||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
/* -----------------------------------------------------------------------------
|
||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||||
* -----------------------------------------------------------------------------
|
||||
* **box**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **divider**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **Column**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **table**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **badge**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **formatToolResult**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **formatToolError**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **keyValue**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **sectionHeader**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **SPINNER_FRAMES**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **progressBar**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import * as c from "./colors.js";
|
||||
|
||||
// ── Box drawing ───────────────────────────────────────────────
|
||||
const BOX_TL = "╭";
|
||||
const BOX_TR = "╮";
|
||||
const BOX_BL = "╰";
|
||||
const BOX_BR = "╯";
|
||||
const BOX_H = "─";
|
||||
const BOX_V = "│";
|
||||
/** Unused "tee" joints; not referenced by {@link box}. */
|
||||
const BOX_ML = "├";
|
||||
const BOX_MR = "┤";
|
||||
|
||||
/** Right-pads `text` to `width` visible columns via {@link stripAnsi}. */
|
||||
function pad(text: string, width: number): string {
|
||||
const visLen = c.stripAnsi(text).length;
|
||||
return text + " ".repeat(Math.max(0, width - visLen));
|
||||
}
|
||||
|
||||
/** Renders `content` in a rounded-corner box with `title` in the top
|
||||
* border. Not currently called; kept as a general-purpose primitive. */
|
||||
export function box(title: string, content: string, width = 60): string {
|
||||
const inner = width - 4;
|
||||
const titleLine = ` ${title} `;
|
||||
const topPad = inner - c.stripAnsi(titleLine).length;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(
|
||||
c.dim(c.cyan(BOX_TL + BOX_H)) +
|
||||
c.bold(c.brightCyan(titleLine)) +
|
||||
c.dim(c.cyan(BOX_H.repeat(Math.max(0, topPad)) + BOX_TR))
|
||||
);
|
||||
|
||||
for (const row of content.split("\n")) {
|
||||
lines.push(c.dim(c.cyan(BOX_V)) + " " + pad(row, inner) + " " + c.dim(c.cyan(BOX_V)));
|
||||
}
|
||||
|
||||
lines.push(c.dim(c.cyan(BOX_BL + BOX_H.repeat(width - 2) + BOX_BR)));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Plain horizontal rule; `repl.ts` imports this without calling it. */
|
||||
export function divider(width = 60): string {
|
||||
return c.dim(c.cyan(BOX_H.repeat(width)));
|
||||
}
|
||||
|
||||
// ── Table ─────────────────────────────────────────────────────
|
||||
|
||||
/** One column definition for {@link table}. */
|
||||
export interface Column {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Auto-sized from header/cell content when omitted. */
|
||||
width?: number;
|
||||
align?: "left" | "right" | "center";
|
||||
/** Styling applied to each cell's raw value before alignment. */
|
||||
color?: (t: string) => string;
|
||||
}
|
||||
|
||||
/** Pads/aligns `text` to `width` visible columns per `align`. */
|
||||
function alignText(
|
||||
text: string,
|
||||
width: number,
|
||||
align: "left" | "right" | "center" = "left"
|
||||
): string {
|
||||
const len = c.stripAnsi(text).length;
|
||||
const diff = Math.max(0, width - len);
|
||||
if (align === "right") return " ".repeat(diff) + text;
|
||||
if (align === "center") {
|
||||
const left = Math.floor(diff / 2);
|
||||
return " ".repeat(left) + text + " ".repeat(diff - left);
|
||||
}
|
||||
return text + " ".repeat(diff);
|
||||
}
|
||||
|
||||
/** Renders `rows` as an ASCII table; used by `repl.ts`'s `printToolList`. */
|
||||
export function table(columns: Column[], rows: Record<string, unknown>[]): string {
|
||||
const colWidths = columns.map((col) => {
|
||||
if (col.width) return col.width;
|
||||
const headerLen = col.label.length;
|
||||
const maxDataLen = rows.reduce((max, row) => {
|
||||
const val = String(row[col.key] ?? "");
|
||||
return Math.max(max, val.length);
|
||||
}, 0);
|
||||
return Math.max(headerLen, maxDataLen) + 2;
|
||||
});
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// Header
|
||||
const headerParts = columns.map((col, i) =>
|
||||
c.bold(c.brightWhite(alignText(col.label, colWidths[i], col.align)))
|
||||
);
|
||||
lines.push(" " + headerParts.join(c.dim(c.cyan(" │ "))));
|
||||
|
||||
// Separator
|
||||
const sep = colWidths.map((w) => BOX_H.repeat(w));
|
||||
lines.push(" " + c.dim(c.cyan(sep.join("─┼─"))));
|
||||
|
||||
// Rows
|
||||
for (const row of rows) {
|
||||
const parts = columns.map((col, i) => {
|
||||
const raw = String(row[col.key] ?? "");
|
||||
const styled = col.color ? col.color(raw) : raw;
|
||||
return alignText(styled, colWidths[i], col.align);
|
||||
});
|
||||
lines.push(" " + parts.join(c.dim(c.cyan(" │ "))));
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ── Status badges ─────────────────────────────────────────────
|
||||
|
||||
/** Color mapping for {@link badge}. */
|
||||
const STATUS_COLORS: Record<string, (t: string) => string> = {
|
||||
active: c.success,
|
||||
completed: c.info,
|
||||
error: c.error,
|
||||
abandoned: c.warn,
|
||||
idle: c.muted,
|
||||
connected: c.info,
|
||||
working: (t) => c.bold(c.brightYellow(t)),
|
||||
ok: c.success,
|
||||
healthy: c.success,
|
||||
unhealthy: c.error,
|
||||
enabled: c.warn,
|
||||
disabled: c.success,
|
||||
};
|
||||
|
||||
/** Renders `[STATUS]` colored via {@link STATUS_COLORS} (falls back to
|
||||
* muted). Used by `repl.ts`'s `printConfig`. */
|
||||
export function badge(status: string): string {
|
||||
const colorFn = STATUS_COLORS[status.toLowerCase()] ?? c.muted;
|
||||
return colorFn(`[${status.toUpperCase()}]`);
|
||||
}
|
||||
|
||||
// ── Tool result formatting ────────────────────────────────────
|
||||
|
||||
/** Renders a successful REPL tool invocation: a header plus the result,
|
||||
* JSON-highlighted via {@link syntaxHighlight}; results over 30 lines are
|
||||
* truncated to 25 (display-only, doesn't affect the actual return value). */
|
||||
export function formatToolResult(name: string, data: unknown, durationMs: number): string {
|
||||
const lines: string[] = [];
|
||||
const header = `${c.success("✔")} ${c.bold(c.brightWhite(name))} ${c.muted(`(${durationMs}ms)`)}`;
|
||||
lines.push(header);
|
||||
|
||||
if (data === null || data === undefined) {
|
||||
lines.push(c.muted(" (no data)"));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const json = typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
||||
const jsonLines = json.split("\n");
|
||||
|
||||
if (jsonLines.length <= 30) {
|
||||
lines.push(syntaxHighlight(json));
|
||||
} else {
|
||||
lines.push(syntaxHighlight(jsonLines.slice(0, 25).join("\n")));
|
||||
lines.push(c.muted(` ... +${jsonLines.length - 25} more lines`));
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Renders a failed REPL tool invocation; given only a plain message
|
||||
* string, unlike {@link errorResult}'s structured `ApiError` handling. */
|
||||
export function formatToolError(name: string, error: string, durationMs: number): string {
|
||||
return (
|
||||
`${c.error("✘")} ${c.bold(c.brightWhite(name))} ${c.muted(`(${durationMs}ms)`)}\n` +
|
||||
` ${c.red(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
// ── JSON syntax highlighting ──────────────────────────────────
|
||||
|
||||
/** Regex-based JSON token coloring; a display heuristic, not a real
|
||||
* tokenizer — safe since input is always `JSON.stringify` output. */
|
||||
function syntaxHighlight(json: string): string {
|
||||
return json.replace(
|
||||
/("(?:\\.|[^"\\])*")\s*(:)?|(\b(?:true|false|null)\b)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
|
||||
(
|
||||
_match,
|
||||
str: string | undefined,
|
||||
colon: string | undefined,
|
||||
bool: string | undefined,
|
||||
num: string | undefined
|
||||
) => {
|
||||
if (str) {
|
||||
if (colon) return c.cyan(str) + c.dim(":");
|
||||
return c.green(str);
|
||||
}
|
||||
if (bool) return c.brightMagenta(bool);
|
||||
if (num) return c.brightYellow(num);
|
||||
return _match;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ── Key-value list ────────────────────────────────────────────
|
||||
|
||||
/** Renders an aligned label/value list. Not currently called — `repl.ts`'s
|
||||
* `printConfig` builds an equivalent layout inline. */
|
||||
export function keyValue(pairs: [string, string][], labelWidth = 20): string {
|
||||
return pairs.map(([k, v]) => ` ${c.label(k.padEnd(labelWidth))} ${v}`).join("\n");
|
||||
}
|
||||
|
||||
// ── Section header ────────────────────────────────────────────
|
||||
|
||||
/** Renders a `◆ Title` heading used throughout `repl.ts`. */
|
||||
export function sectionHeader(title: string): string {
|
||||
return `\n ${c.bold(c.brightCyan("◆"))} ${c.bold(c.brightWhite(title))}\n`;
|
||||
}
|
||||
|
||||
// ── Spinner frames (for async operations) ─────────────────────
|
||||
|
||||
/** Braille spinner animation frames; no current caller drives one. */
|
||||
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
/** Renders a block progress bar with a percentage label, clamped to
|
||||
* `[0, 1]`. No current caller reports incremental progress. */
|
||||
export function progressBar(current: number, total: number, width = 30): string {
|
||||
const pct = Math.min(1, Math.max(0, current / total));
|
||||
const filled = Math.round(pct * width);
|
||||
const empty = width - filled;
|
||||
const bar = c.brightCyan("█".repeat(filled)) + c.dim("░".repeat(empty));
|
||||
const label = c.muted(`${Math.round(pct * 100)}%`);
|
||||
return ` ${bar} ${label}`;
|
||||
}
|
||||
Reference in New Issue
Block a user