diff --git a/.env.example b/.env.example index 7bc3eab..d142ec2 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,9 @@ CLUSTERS_JSON=[{"name":"primary","elasticsearchUrl":"https://your-cluster.es.clo # Alternative: load the same JSON from a file. # CLUSTERS_FILE=/absolute/path/to/clusters.json + +# Telemetry endpoint override. Defaults to production (telemetry.elastic.co). +# Set to `staging` to point at telemetry-staging.elastic.co — useful when +# verifying dashboards or working on the MCP App's analytics locally. +# See docs/telemetry.md for the full event catalog and opt-out story. +# MCP_APP_TELEMETRY_ENV=staging diff --git a/README.md b/README.md index b110713..f292930 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ When a user asks Claude to triage alerts or run a threat hunt, Claude calls a mo See [docs/architecture.md](docs/architecture.md) for details on how views are built, how the UI communicates with the server, and key design decisions. +### Telemetry + +The MCP App emits anonymised usage events via `@elastic/ebt`. Shipping is mirrored to the user's Kibana telemetry opt-in — nothing leaves the process unless Kibana reports `optIn === true`. See [docs/telemetry.md](docs/telemetry.md) for the event catalog, what's collected, and how to opt out. + ### Skills The `skills/` directory contains [Claude Skills](https://claude.com/docs/skills/overview) — `SKILL.md` files that teach Claude *when* and *how* to use the tools. See [docs/setup-skills.md](docs/setup-skills.md) for installation instructions. diff --git a/docs/architecture.md b/docs/architecture.md index 728673f..799549d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,3 +42,6 @@ The `generate-attack-discovery` tool triggers Kibana's Attack Discovery API with ### Kibana 9.x Compatibility All Kibana API calls include `elastic-api-version: 2023-10-31` headers, `x-elastic-internal-origin: Kibana` for internal APIs, and camelCase field names. + +### Telemetry +The MCP App emits a small set of anonymised usage events via `@elastic/ebt`, mirrored to the user's Kibana telemetry opt-in. The shipper is fail-closed: events never leave the process unless Kibana reports `optIn === true`. See [`docs/telemetry.md`](./telemetry.md) for the event catalog and opt-out story. diff --git a/docs/telemetry.md b/docs/telemetry.md new file mode 100644 index 0000000..ddb8e5b --- /dev/null +++ b/docs/telemetry.md @@ -0,0 +1,215 @@ +# Telemetry + +The Elastic Security MCP App emits a small set of anonymised usage events +via [`@elastic/ebt`](https://www.npmjs.com/package/@elastic/ebt) so the +team can see which views and tools are being used. Events ship to +`telemetry.elastic.co` and are subject to the same opt-in the user +controls in Kibana — the app never reports anything when Kibana +telemetry is disabled. + +## Opt-in source of truth + +On MCP server start the app fetches the user's telemetry config from the +default-cluster Kibana: + +``` +GET /api/telemetry/v2/config +``` + +The response's `optIn` field maps to the analytics client's shipping +state: + +| Kibana `optIn` | App behaviour | +|------------------|-------------------------------------| +| `true` | Events ship to `telemetry.elastic.co` | +| `false` | Events queued in-memory, then dropped | +| `null` | Treated as opted-out | +| Fetch error | Treated as opted-out (fail-closed) | + +The `optIn` is read once at startup; there is no polling. Restart the +MCP server (or your MCP host) after flipping the Kibana setting for the +change to take effect. + +## What gets collected + +### `mcp_tool_called` (server-side) + +Emitted every time an MCP tool handler returns or throws. Wrapped +around every model-facing tool and every app-only tool _except_ +`report-analytics-event` (which would otherwise duplicate the +`view_rendered` traffic). + +| Field | Type | Notes | +|---------------|---------|--------------------------------------------------------| +| `tool_id` | keyword | One of the values listed under "Allowed tool ids" below | +| `duration_ms` | long | Wall-clock duration of the handler, in milliseconds | +| `success` | boolean | `true` if the handler resolved, `false` if it threw | + +### `view_rendered` (client-side) + +Emitted once per mount of each top-level React view, via the app-only +`report-analytics-event` MCP tool. + +| Field | Type | Allowed values (closed enum) | +|-----------|---------|-------------------------------------------------------------------------------------------------------------| +| `view_id` | keyword | `alert-triage`, `attack-discovery`, `case-management`, `detection-rules`, `sample-data`, `threat-hunt` | + +## Allowed `tool_id` values + +`tool_id` is bound to the registered MCP tool name, so renames here are +schema-impacting events for downstream dashboards. The current set: + +``` +acknowledge-alert +acknowledge-alerts-bulk +acknowledge-discoveries +add-case-comment +approve-discoveries +assess-discovery-confidence +attach-alert-to-case +check-existing-sample-data +cleanup-sample-data +create-case +create-rule +create-rules-for-scenario +enrich-discovery +execute-esql +find-rules +generate-attack-discovery +generate-sample-data +generate-scenario +get-alert-context +get-case +get-case-alerts +get-case-comments +get-entity-detail +get-generation-status +get-mapping +get-rule +get-user-profile +investigate-entity +list-ai-connectors +list-cases +list-indices +manage-cases +manage-exceptions +manage-rules +noisy-rules +patch-rule +poll-alerts +poll-discoveries +threat-hunt +toggle-rule +triage-alerts +triage-attack-discoveries +unacknowledge-alert +update-case +validate-query +``` + +Add new tools by registering them with `registerTrackedAppTool` — they +will start appearing automatically once shipped. + +## Context attached to every event + +The Elastic V3 shipper enriches each event with a small context block +derived from the **default** cluster: + +| Field | Source | Notes | +|-------------------|-------------------------------------|------------------------------------------------| +| `cluster_uuid` | Elasticsearch `GET /` | Required by the V3 shipper; events do not ship without it | +| `cluster_version` | Elasticsearch `GET /` `version.number` | Stack version | +| `license_id` | Elasticsearch `GET /_license` | Optional | +| `license_status` | Elasticsearch `GET /_license` | Optional | +| `license_type` | Elasticsearch `GET /_license` | Optional | +| `mcp_app_version` | `package.json` `version` | Version of the MCP App that emitted the event | + +`cluster_name` is deliberately **not** collected. It's user-controlled +and frequently contains company / environment identifiers, so shipping +it would undermine the "anonymised" framing of this feed. + +### Segmentation granularity + +There is no `install_id` — two MCP App installs against the same Elastic +cluster will share `cluster_uuid` and are not distinguishable in the +telemetry stream. This is fine for v1; if per-install segmentation +becomes important later we can stamp a random UUID into the credential +file on first run and add it as a context field. + +## What does **not** get collected + +The schemas above are closed — no free-form text, no PII, no Kibana +user identifiers, no alert / case / rule bodies, no ES|QL queries. +Adding a new field requires: + +1. Extending `McpToolCalledEvent` / `ViewRenderedEvent` in + `src/elastic/analytics/events.ts` (and its EBT schema sibling). +2. Adding the field to the registered context provider or event + definition. +3. Updating this document. + +The `report-analytics-event` MCP tool that the frontend uses to forward +client-side events accepts a strict Zod schema (`eventType: z.literal`, +`viewId: z.enum`) so a malicious or buggy view cannot smuggle free-form +text into the pipeline. + +## Opting out + +End-users have one knob: the **Kibana** telemetry setting. The MCP +App mirrors it; flipping it off in Kibana stops the MCP App from +shipping any events. Restart the MCP host after flipping the setting. + +### Developer escape hatches + +| Environment variable | Effect | +|-------------------------------|--------------------------------------------------------------------------------------------------------------| +| `MCP_APP_TELEMETRY_ENV=staging` | Ships events to `telemetry-staging.elastic.co` instead of production. Useful for local dev / dashboard work. | +| `NODE_ENV=production` | Currently only affects EBT's internal `isDev` flag (logging verbosity). Does **not** disable shipping. | + +The MCP App does not provide an "always off regardless of Kibana" +override beyond unsetting the user's Kibana opt-in. If you need that, +turn off Kibana telemetry on your dev cluster — the fail-closed +behaviour does the rest. + +## Bundle-size impact + +Adding `@elastic/ebt@^1.4.1` (which transitively pulls `rxjs`, +`fp-ts`, `io-ts`, `moment`, `js-sha256`, `lodash.get`, `lodash.has`, +`node-fetch@2`, and `@babel/runtime`) increases the bundled +`dist/main.bundle.mjs` by roughly **+560 KB raw / +113 KB gzipped** +(~15% raw, ~17% gzipped) on a fresh measurement. This is a one-time +download for `.mcpb` installs; the bundle is loaded into the MCP host +once and reused across sessions. + +`node-fetch@2` is redundant on Node 22 (native `fetch`) but EBT pulls +it unconditionally; tree-shaking it out would require a fork of +`@elastic/ebt`. Acceptable for now. + +## Where things live in the codebase + +``` +src/elastic/analytics/ + analytics-client.ts - AnalyticsClient interface (what the rest of the app sees) + create-analytics-client.ts - Factory: registers ElasticV3ServerShipper + EBT event types + context providers + context-loader.ts - One-shot GET / + GET /_license on startup + events.ts - Event type IDs and Zod-typed payload schemas + index.ts - Public module surface + +src/elastic/client/telemetryConfigClient.ts + Wraps GET /api/telemetry/v2/config on the default-cluster Kibana + +src/elastic/service/telemetryService.ts + Fetches the telemetry config and mirrors optIn → analytics.setOptIn + +src/tools/tracked-app-tool.ts + Drop-in replacement for `registerAppTool` that emits `mcp_tool_called` + +src/tools/analytics.ts + Registers the app-only `report-analytics-event` MCP tool + +src/shared/hooks/McpAppProvider.tsx, useMcpApp.ts, useAnalytics.ts + React context + hooks: provider owns the McpApp; useAnalytics reads it + +src/shared/analytics-events.ts + Single source of truth for the view-id enum, imported by both server and views +``` diff --git a/main.ts b/main.ts index d9c7ece..d2c0bf2 100644 --- a/main.ts +++ b/main.ts @@ -14,9 +14,23 @@ import { createCredentialClient, type CredentialClient, } from "./src/elastic/credential-client/index.js"; +import { createEsClient } from "./src/elastic/es-client/index.js"; +import { createKibanaClient } from "./src/elastic/kibana-client/index.js"; +import { + createAnalyticsClient, + createContextLoader, + type AnalyticsClient, +} from "./src/elastic/analytics/index.js"; +import { TelemetryConfigClient } from "./src/elastic/client/telemetryConfigClient.js"; +import { TelemetryService } from "./src/elastic/service/telemetryService.js"; import { createServer } from "./src/server.js"; +import { createStderrLogger } from "./src/shared/logger.js"; +import { readPackageVersion } from "./src/shared/package-version.js"; const isStdio = process.argv.includes("--stdio"); +const logger = createStderrLogger(); +const serverLogger = logger.child("elastic-security"); +const telemetryLogger = logger.child("telemetry"); /** * Format a startup error so it's actually useful in the MCP host's log @@ -60,19 +74,114 @@ function fatal(prefix: string, err: unknown): void { // Built once at startup so HTTP mode doesn't re-read CLUSTERS_FILE and // re-run Zod on every POST /mcp, and so config errors fail before the // listener binds. +// +// The whole block is `fatal()`-guarded because any synchronous throw +// during initialisation — bad cluster config, EBT shipper construction +// failure, or anything similar — must surface as a readable line in +// the MCP host's log pane rather than the opaque "Server transport +// closed unexpectedly" the host reports for a silent crash. let credentialClient: CredentialClient; +let analytics: AnalyticsClient; try { credentialClient = createCredentialClient(); + + // Default cluster is the analytics seed: telemetry config opt-in is + // read from it and cluster/license context is pulled from it. These + // are constructed once at startup and shared across all per-request + // servers in HTTP mode so the EBT shipper has a single context view. + const defaultCredentials = credentialClient.get(); + analytics = createAnalyticsClient({ + mcpAppVersion: readPackageVersion(import.meta.url), + logger: telemetryLogger, + }); + + const defaultEsClient = createEsClient(defaultCredentials); + const defaultKibanaClient = createKibanaClient(defaultCredentials); + const telemetryConfigClient = new TelemetryConfigClient({ + kibanaClient: defaultKibanaClient, + }); + const telemetryService = new TelemetryService({ + telemetryConfigClient, + analytics, + logger: telemetryLogger, + }); + const contextLoader = createContextLoader({ + esClient: defaultEsClient, + analytics, + logger: telemetryLogger, + }); + + // Fire-and-forget: a slow or unreachable Kibana / Elasticsearch must + // never block transport bind. The analytics client starts opted-out + // at construction and only flips on after the Kibana telemetry + // config fetch succeeds with `optIn === true`, so the gap is safe. + void Promise.allSettled([ + telemetryService.applyOptIn(), + contextLoader.loadAndApply(), + ]); } catch (err) { fatal("startup failed", err); - // `fatal()` schedules `process.exit(1)`; rethrow so TS sees this branch - // as terminating and treats `credentialClient` as definitely assigned. + // `fatal()` schedules `process.exit(1)`; rethrow so TS sees this + // branch as terminating and treats `credentialClient` / `analytics` + // as definitely assigned. throw err; } +/** + * Upper bound on how long we'll wait for `analytics.shutdown()` to + * drain in-flight EBT requests before we exit. If the telemetry + * endpoint is unreachable the shipper can sit waiting indefinitely; + * the host (Claude Desktop / Cursor) would then escalate to SIGKILL. + * 1.5 s is generous for a fire-and-forget queue and short enough that + * the host never notices. + */ +const ANALYTICS_SHUTDOWN_TIMEOUT_MS = 1500; + +// Flush any queued events before the host kills us. Both signals are +// handled because Claude Desktop sends SIGINT to the stdio child but +// container runtimes send SIGTERM. The closure captures a single +// in-flight Promise so re-entry (e.g. SIGTERM followed by SIGINT) +// returns the same handle rather than starting a second drain. +const shutdown = ((): ((signal: NodeJS.Signals) => Promise) => { + let started: Promise | null = null; + return (signal) => { + if (started) return started; + started = (async () => { + try { + let flushed = false; + await Promise.race([ + analytics.shutdown().then(() => { + flushed = true; + }), + new Promise((resolve) => { + const timer = setTimeout(resolve, ANALYTICS_SHUTDOWN_TIMEOUT_MS); + timer.unref(); + }), + ]); + if (flushed) { + serverLogger.info("analytics events flushed before shutdown"); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + serverLogger.error(`analytics shutdown failed: ${message}`); + } + // Re-raise with the conventional 128+signo exit code so init + // systems can tell us apart from a normal `exit(0)`. + process.exit(signal === "SIGINT" ? 130 : 143); + })(); + return started; + }; +})(); + +for (const signal of ["SIGTERM", "SIGINT"] as const) { + process.on(signal, () => { + void shutdown(signal); + }); +} + if (isStdio) { try { - const server = createServer({ credentialClient }); + const server = createServer({ credentialClient, analytics }); const transport = new StdioServerTransport(); await server.connect(transport); } catch (err) { @@ -88,14 +197,14 @@ if (isStdio) { // `credentialClient` above so this stays cheap. app.post("/mcp", async (req, res) => { try { - const server = createServer({ credentialClient }); + const server = createServer({ credentialClient, analytics }); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); res.on("close", () => transport.close()); await server.connect(transport); await transport.handleRequest(req, res, req.body); } catch (err) { const message = err instanceof Error ? err.message : String(err); - console.error(`[elastic-security] request failed: ${message}`); + serverLogger.error(`request failed: ${message}`); if (!res.headersSent) { res.writeHead(500).end(JSON.stringify({ error: message })); } @@ -112,13 +221,13 @@ if (isStdio) { const port = parseInt(process.env.PORT || "3001", 10); const httpServer = app.listen(port, () => { - console.log(`Elastic Security MCP App server running on http://localhost:${port}/mcp`); + serverLogger.info(`HTTP server running on http://localhost:${port}/mcp`); }); httpServer.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { - console.error(`Error: Port ${port} is already in use. Set a different port with the PORT environment variable.`); + serverLogger.error(`Port ${port} is already in use. Set a different port with the PORT environment variable.`); } else { - console.error(`Server error: ${err.message}`); + serverLogger.error(`Server error: ${err.message}`); } process.exit(1); }); diff --git a/package-lock.json b/package-lock.json index 08e9dde..ca3cfcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.2", "license": "Elastic-2.0", "dependencies": { + "@elastic/ebt": "^1.4.1", "@elastic/highlightjs-esql": "^1.2.3", "@elastic/monaco-esql": "^3.3.1", "@modelcontextprotocol/ext-apps": "^1.3.1", @@ -180,7 +181,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -363,6 +363,23 @@ "node": ">=20.19.0" } }, + "node_modules/@elastic/ebt": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@elastic/ebt/-/ebt-1.4.1.tgz", + "integrity": "sha512-8kvqG4cU/+V0CvJcXH9Bh0jyHmqvmVwhsOiGW0ZS1ylHMMgVP95186W3quL8luivoAg7PNIUj69OsV2OjLDmwg==", + "license": "SSPL-1.0 OR Elastic License 2.0", + "dependencies": { + "@babel/runtime": "^7.28.4", + "fp-ts": "^2.16.11", + "io-ts": "^2.2.22", + "js-sha256": "^0.11.1", + "lodash.get": "^4.4.2", + "lodash.has": "^4.5.2", + "moment": "^2.30.1", + "node-fetch": "^2.6.7", + "rxjs": "^7.8.2" + } + }, "node_modules/@elastic/highlightjs-esql": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@elastic/highlightjs-esql/-/highlightjs-esql-1.2.3.tgz", @@ -4424,6 +4441,12 @@ "node": ">= 0.6" } }, + "node_modules/fp-ts": { + "version": "2.16.11", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.11.tgz", + "integrity": "sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w==", + "license": "MIT" + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -4737,6 +4760,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/io-ts": { + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.2.22.tgz", + "integrity": "sha512-FHCCztTkHoV9mdBsHpocLpdTAfh956ZQcIkWQxxS0U5HT53vtrcuYdQneEJKH6xILaLNzXVl2Cvwtoy8XNN0AA==", + "license": "MIT", + "peerDependencies": { + "fp-ts": "^2.5.0" + } + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -4888,6 +4920,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-sha256": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz", + "integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5391,6 +5429,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, + "node_modules/lodash.has": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/lodash.has/-/lodash.has-4.5.2.tgz", + "integrity": "sha512-rnYUdIo6xRCJnQmbVFEwcxF144erlD+M3YcJUVesflU9paQaE8p+fJDcIQrlMYbxoANFL+AB9hZrzSBBk5PL+g==", + "license": "MIT" + }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -5728,6 +5779,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/monaco-editor": { "version": "0.55.1", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", @@ -5799,6 +5859,48 @@ "node": ">= 0.6" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6348,7 +6450,6 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -6888,7 +6989,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tsx": { diff --git a/package.json b/package.json index 983e3ca..11991b3 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "node": ">=22" }, "dependencies": { + "@elastic/ebt": "^1.4.1", "@elastic/highlightjs-esql": "^1.2.3", "@elastic/monaco-esql": "^3.3.1", "@modelcontextprotocol/ext-apps": "^1.3.1", diff --git a/src/elastic/analytics/analytics-client.ts b/src/elastic/analytics/analytics-client.ts new file mode 100644 index 0000000..bc48312 --- /dev/null +++ b/src/elastic/analytics/analytics-client.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + McpToolCalledEbtPayload, + ViewRenderedEbtPayload, +} from "./events.js"; + +/** + * Elasticsearch cluster context attached to outgoing telemetry events. + * + * `cluster_uuid` is required by the Elastic V3 shipper to build the + * `x-elastic-cluster-id` header — events will not ship until at least + * one `cluster_uuid` has been published into the analytics context. + * + * `cluster_name` is intentionally **not** shipped: it's user-controlled + * and frequently contains company / environment identifiers that would + * compromise the "anonymised" framing of this feed. + */ +export interface ClusterContext { + readonly cluster_uuid: string; + readonly cluster_version: string; +} + +export interface LicenseContext { + readonly license_id?: string; + readonly license_status?: string; + readonly license_type?: string; +} + +/** + * MCP App's typed telemetry surface. + * + * Wraps `@elastic/ebt`'s `IAnalyticsClient` so the rest of the codebase + * never touches EBT or RxJS directly. Each public method is typed + * against the matching payload in `events.ts`, so a schema change there + * is caught at compile time in every caller. + * + * The client always starts opted-out; call `setOptIn(true)` once the + * Kibana telemetry config has been resolved (see `TelemetryService`). + */ +export interface AnalyticsClient { + trackToolCalled(event: McpToolCalledEbtPayload): void; + + trackViewRendered(event: ViewRenderedEbtPayload): void; + + setOptIn(enabled: boolean): void; + + setClusterContext(ctx: ClusterContext): void; + + setLicenseContext(ctx: LicenseContext): void; + + shutdown(): Promise; +} + +export const noopAnalyticsClient: AnalyticsClient = { + trackToolCalled: () => {}, + trackViewRendered: () => {}, + setOptIn: () => {}, + setClusterContext: () => {}, + setLicenseContext: () => {}, + shutdown: async () => {}, +}; diff --git a/src/elastic/analytics/context-loader.test.ts b/src/elastic/analytics/context-loader.test.ts new file mode 100644 index 0000000..a9fbafc --- /dev/null +++ b/src/elastic/analytics/context-loader.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { describe, it, expect, vi } from "vitest"; +import { createContextLoader } from "./context-loader.js"; +import { createMockAnalyticsClient } from "../../test/helpers/mockAnalytics.js"; +import { + createMockEsClient, + dataEnvelope, +} from "../../test/helpers/mockHttpClient.js"; + +describe("createContextLoader().loadAndApply", () => { + it("publishes cluster + license context from GET / and GET /_license", async () => { + const esClient = createMockEsClient(); + esClient.get.mockImplementation(async (path: string) => { + if (path === "/") { + return dataEnvelope({ + cluster_uuid: "uuid-1", + cluster_name: "primary", + version: { number: "8.99.0" }, + }); + } + if (path === "/_license") { + return dataEnvelope({ + license: { uid: "lic-1", status: "active", type: "platinum" }, + }); + } + throw new Error(`unexpected GET ${path}`); + }); + const analytics = createMockAnalyticsClient(); + + const loader = createContextLoader({ esClient, analytics }); + await loader.loadAndApply(); + + expect(analytics.setClusterContext).toHaveBeenCalledWith({ + cluster_uuid: "uuid-1", + cluster_version: "8.99.0", + }); + expect(analytics.setLicenseContext).toHaveBeenCalledWith({ + license_id: "lic-1", + license_status: "active", + license_type: "platinum", + }); + }); + + it("skips cluster context when required fields are missing", async () => { + const esClient = createMockEsClient(); + esClient.get.mockImplementation(async (path: string) => { + if (path === "/") return dataEnvelope({ cluster_uuid: "uuid-1" }); + if (path === "/_license") return dataEnvelope({}); + throw new Error(`unexpected GET ${path}`); + }); + const analytics = createMockAnalyticsClient(); + const warn = vi.fn(); + + const loader = createContextLoader({ esClient, analytics, logger: { warn } }); + await loader.loadAndApply(); + + expect(analytics.setClusterContext).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("missing required cluster fields"), + ); + }); + + it("swallows cluster fetch errors and still attempts the license fetch", async () => { + const esClient = createMockEsClient(); + esClient.get.mockImplementation(async (path: string) => { + if (path === "/") throw new Error("cluster down"); + if (path === "/_license") { + return dataEnvelope({ + license: { uid: "lic-1", status: "active", type: "gold" }, + }); + } + throw new Error(`unexpected GET ${path}`); + }); + const analytics = createMockAnalyticsClient(); + const warn = vi.fn(); + + const loader = createContextLoader({ esClient, analytics, logger: { warn } }); + await loader.loadAndApply(); + + expect(analytics.setClusterContext).not.toHaveBeenCalled(); + expect(analytics.setLicenseContext).toHaveBeenCalledWith({ + license_id: "lic-1", + license_status: "active", + license_type: "gold", + }); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("cluster down"), + ); + }); +}); diff --git a/src/elastic/analytics/context-loader.ts b/src/elastic/analytics/context-loader.ts new file mode 100644 index 0000000..6a43a6b --- /dev/null +++ b/src/elastic/analytics/context-loader.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EsClient } from "../es-client/index.js"; +import type { AnalyticsClient } from "./analytics-client.js"; +import { createStderrLogger, type Logger } from "../../shared/logger.js"; + +/** + * Minimal shape of `GET /` on Elasticsearch — we only consume the two + * fields the analytics context needs, so the type is intentionally + * narrow. `cluster_name` is deliberately not read: it's user-controlled + * and would compromise the anonymised framing of the telemetry feed. + */ +interface EsRootResponse { + readonly cluster_uuid?: string; + readonly version?: { readonly number?: string }; +} + +/** + * Minimal shape of `GET /_license` on Elasticsearch — only what the + * analytics context schema needs. + */ +interface EsLicenseResponse { + readonly license?: { + readonly uid?: string; + readonly status?: string; + readonly type?: string; + }; +} + +export interface ContextLoader { + /** + * One-shot fetch of cluster + license information, applied to the + * analytics client via `setClusterContext` / `setLicenseContext`. + * + * Failures are logged and swallowed: a missing context just means + * the EBT shipper won't ship until something fills it in (fail-closed). + */ + loadAndApply(): Promise; +} + +export interface CreateContextLoaderDeps { + readonly esClient: EsClient; + readonly analytics: Pick< + AnalyticsClient, + "setClusterContext" | "setLicenseContext" + >; + readonly logger?: Pick; +} + +/** + * Build a {@link ContextLoader} that publishes default-cluster + * Elasticsearch identity into the analytics context on startup. + * + * Each half (cluster info, license info) is fetched independently — + * a missing license still allows cluster context to be applied. + */ +export function createContextLoader( + deps: CreateContextLoaderDeps, +): ContextLoader { + const { + esClient, + analytics, + logger = createStderrLogger(["telemetry"]), + } = deps; + + return { + async loadAndApply(): Promise { + await Promise.all([loadCluster(), loadLicense()]); + + async function loadCluster(): Promise { + try { + const { data } = await esClient.get("/"); + if (!data.cluster_uuid || !data.version?.number) { + logger.warn( + "elasticsearch root response missing required cluster fields; skipping cluster context", + ); + return; + } + analytics.setClusterContext({ + cluster_uuid: data.cluster_uuid, + cluster_version: data.version.number, + }); + } catch (err) { + logger.warn( + `failed to load cluster context: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + + async function loadLicense(): Promise { + try { + const { data } = await esClient.get("/_license"); + if (!data.license) return; + analytics.setLicenseContext({ + license_id: data.license.uid, + license_status: data.license.status, + license_type: data.license.type, + }); + } catch (err) { + logger.warn( + `failed to load license context: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + }, + }; +} diff --git a/src/elastic/analytics/create-analytics-client.test.ts b/src/elastic/analytics/create-analytics-client.test.ts new file mode 100644 index 0000000..8204aee --- /dev/null +++ b/src/elastic/analytics/create-analytics-client.test.ts @@ -0,0 +1,162 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; +import { createAnalyticsClient } from "./create-analytics-client.js"; +import type { Logger } from "../../shared/logger.js"; + +describe("createAnalyticsClient", () => { + function createMockLogger(): Pick { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + } + + beforeEach(() => { + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("exposes the typed AnalyticsClient surface", () => { + const client = createAnalyticsClient({ mcpAppVersion: "1.2.3" }); + + expect(typeof client.trackToolCalled).toBe("function"); + expect(typeof client.trackViewRendered).toBe("function"); + expect(typeof client.setOptIn).toBe("function"); + expect(typeof client.setClusterContext).toBe("function"); + expect(typeof client.setLicenseContext).toBe("function"); + expect(typeof client.shutdown).toBe("function"); + }); + + it("does not throw on track* calls before opt-in (events are queued)", () => { + const client = createAnalyticsClient({ + mcpAppVersion: "1.2.3", + logger: createMockLogger(), + }); + + expect(() => { + client.trackToolCalled({ + tool_id: "triage-alerts", + duration_ms: 12, + success: true, + }); + client.trackViewRendered({ view_id: "alert-triage" }); + }).not.toThrow(); + }); + + it("does not write EBT debug output to stdout with the default logger", () => { + const debug = vi.spyOn(console, "debug").mockImplementation(() => undefined); + const info = vi.spyOn(console, "info").mockImplementation(() => undefined); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const client = createAnalyticsClient({ mcpAppVersion: "1.2.3" }); + + client.trackViewRendered({ view_id: "alert-triage" }); + + expect(debug).not.toHaveBeenCalled(); + expect(info).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); + + it("does not log tracked telemetry events before opt-in", () => { + const logger = createMockLogger(); + const client = createAnalyticsClient({ mcpAppVersion: "1.2.3", logger }); + + client.trackToolCalled({ + tool_id: "triage-alerts", + duration_ms: 12, + success: true, + }); + client.trackViewRendered({ view_id: "alert-triage" }); + + expect(logger.info).not.toHaveBeenCalledWith( + expect.stringContaining("reported event:"), + ); + }); + + it("logs each tracked telemetry event through the injected logger after opt-in", () => { + const logger = createMockLogger(); + const client = createAnalyticsClient({ mcpAppVersion: "1.2.3", logger }); + + client.setOptIn(true); + client.trackToolCalled({ + tool_id: "triage-alerts", + duration_ms: 12, + success: true, + }); + client.trackViewRendered({ view_id: "alert-triage" }); + + expect(logger.info).toHaveBeenCalledWith( + 'reported event: send_to=production type=mcp_tool_called payload={"tool_id":"triage-alerts","duration_ms":12,"success":true}', + ); + expect(logger.info).toHaveBeenCalledWith( + 'reported event: send_to=production type=view_rendered payload={"view_id":"alert-triage"}', + ); + }); + + it("logs the configured telemetry destination with tracked events", () => { + const logger = createMockLogger(); + const client = createAnalyticsClient({ + mcpAppVersion: "1.2.3", + sendTo: "staging", + logger, + }); + + client.setOptIn(true); + client.trackViewRendered({ view_id: "alert-triage" }); + + expect(logger.info).toHaveBeenCalledWith( + 'reported event: send_to=staging type=view_rendered payload={"view_id":"alert-triage"}', + ); + }); + + it("stops logging tracked telemetry events after opt-out", () => { + const logger = createMockLogger(); + const client = createAnalyticsClient({ mcpAppVersion: "1.2.3", logger }); + + client.setOptIn(true); + client.trackViewRendered({ view_id: "alert-triage" }); + client.setOptIn(false); + client.trackViewRendered({ view_id: "threat-hunt" }); + + expect(logger.info).toHaveBeenCalledWith( + 'reported event: send_to=production type=view_rendered payload={"view_id":"alert-triage"}', + ); + expect(logger.info).not.toHaveBeenCalledWith( + 'reported event: send_to=production type=view_rendered payload={"view_id":"threat-hunt"}', + ); + }); + + it("setOptIn / setClusterContext / setLicenseContext are side-effect-only and return void", () => { + const client = createAnalyticsClient({ mcpAppVersion: "1.2.3" }); + + expect(client.setOptIn(true)).toBeUndefined(); + expect( + client.setClusterContext({ + cluster_uuid: "uuid-1", + cluster_version: "8.99.0", + }), + ).toBeUndefined(); + expect( + client.setLicenseContext({ + license_id: "lic-1", + license_status: "active", + license_type: "platinum", + }), + ).toBeUndefined(); + }); + + it("shutdown() resolves to a Promise", async () => { + const client = createAnalyticsClient({ mcpAppVersion: "1.2.3" }); + await expect(client.shutdown()).resolves.toBeUndefined(); + }); +}); diff --git a/src/elastic/analytics/create-analytics-client.ts b/src/elastic/analytics/create-analytics-client.ts new file mode 100644 index 0000000..69e6f20 --- /dev/null +++ b/src/elastic/analytics/create-analytics-client.ts @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createAnalytics } from "@elastic/ebt/client/index.js"; +import type { AnalyticsClientInitContext } from "@elastic/ebt/client/index.js"; +import { ElasticV3ServerShipper } from "@elastic/ebt/shippers/elastic_v3/server/index.js"; +import { createStderrLogger, type Logger } from "../../shared/logger.js"; + +/** + * EBT exports `AnalyticsClientInitContext` but not the `Logger` type + * directly. Derive it from the public surface so we don't reach into + * the package's internal paths. + */ +type EbtLogger = AnalyticsClientInitContext["logger"]; +import { BehaviorSubject } from "rxjs"; +import type { AnalyticsClient, ClusterContext, LicenseContext } from "./analytics-client.js"; +import { + EVENT_TYPES, + mcpToolCalledEventDef, + viewRenderedEventDef, + type McpToolCalledEbtPayload, + type ViewRenderedEbtPayload, +} from "./events.js"; + +const CHANNEL_NAME = "elastic-security-mcp-app"; +const noop = (): void => undefined; +export type TelemetrySendTo = "production" | "staging"; + +const defaultLogger = createStderrLogger(["telemetry"]); +const defaultLoggerBase: Pick = { + debug: noop, + info: (msg) => defaultLogger.info(msg), + warn: (msg) => defaultLogger.warn(msg), + error: (msg) => defaultLogger.error(msg), +}; + +/** + * The base URL of the V3 telemetry endpoint, selected from + * `MCP_APP_TELEMETRY_ENV`. Defaults to production for end-user + * installs (`.mcpb` ships without the env var set). + */ +export function resolveTelemetrySendTo(env: string | undefined): TelemetrySendTo { + return env === "staging" ? "staging" : "production"; +} + +function baseUrlFor(sendTo: TelemetrySendTo): string { + return sendTo === "production" + ? "https://telemetry.elastic.co" + : "https://telemetry-staging.elastic.co"; +} + +function logReportedEvent( + logger: Pick, + sendTo: TelemetrySendTo, + eventType: string, + event: McpToolCalledEbtPayload | ViewRenderedEbtPayload, +): void { + logger.info( + `reported event: send_to=${sendTo} type=${eventType} payload=${JSON.stringify(event)}`, + ); +} + +/** + * Adapts a `Console`-shaped logger to EBT's `Logger` interface + * (which requires `.get(...)` to return a child logger). We don't + * use the EBT debug logging for anything more than visibility into + * shipper internals during local dev, so a no-op child is fine. + */ +function adaptLogger( + base: Pick, +): EbtLogger { + const logger: EbtLogger = { + debug: (msg) => base.debug(typeof msg === "function" ? msg() : msg), + info: (msg) => base.info(typeof msg === "function" ? msg() : msg), + warn: (msg) => { + if (msg instanceof Error) base.warn(msg); + else base.warn(typeof msg === "function" ? msg() : msg); + }, + error: (msg) => { + if (msg instanceof Error) base.error(msg); + else base.error(typeof msg === "function" ? msg() : msg); + }, + get: () => logger, + }; + return logger; +} + +export interface CreateAnalyticsClientOptions { + readonly mcpAppVersion: string; + readonly sendTo?: TelemetrySendTo; + readonly logger?: Pick; +} + +/** + * Build a {@link AnalyticsClient} backed by `@elastic/ebt`. + * + * The client is constructed opted-out — `setOptIn(true)` must be + * called explicitly (via `TelemetryService.applyOptIn()`) once the + * user's Kibana telemetry opt-in has been resolved. + * + * Encapsulates everything EBT/RxJS-shaped so nothing outside this + * module needs to import either. + */ +export function createAnalyticsClient( + opts: CreateAnalyticsClientOptions, +): AnalyticsClient { + const sendTo = opts.sendTo ?? resolveTelemetrySendTo(process.env.MCP_APP_TELEMETRY_ENV); + const baseUrl = baseUrlFor(sendTo); + const logger = adaptLogger(opts.logger ?? defaultLoggerBase); + let optedIn = false; + + // `.mcpb` installs launch the MCP child without setting `NODE_ENV`, so + // we opt **into** dev mode only when it's set explicitly to + // `development`. The previous `!== "production"` check made dev the + // default for every end-user install, which produced noisy shipper + // logs in the host's stderr pane. + const ebt = createAnalytics({ + isDev: process.env.NODE_ENV === "development", + logger, + }); + + ebt.registerShipper(ElasticV3ServerShipper, { + channelName: CHANNEL_NAME, + version: opts.mcpAppVersion, + buildShipperHeaders: (clusterUuid, version, licenseId) => ({ + "content-type": "application/x-ndjson", + "x-elastic-cluster-id": clusterUuid, + "x-elastic-stack-version": version, + ...(licenseId ? { "x-elastic-license-id": licenseId } : {}), + }), + buildShipperUrl: ({ channelName }) => `${baseUrl}/v3/send/${channelName}`, + }); + + // Fail-closed at construction. The TelemetryService will flip this on + // once it has confirmed the user's Kibana opt-in is `true`. + ebt.optIn({ global: { enabled: false } }); + + ebt.registerEventType(mcpToolCalledEventDef); + ebt.registerEventType(viewRenderedEventDef); + + const cluster$ = new BehaviorSubject(undefined); + const license$ = new BehaviorSubject(undefined); + + ebt.registerContextProvider({ + name: "elasticsearch info", + // The shipper tolerates `undefined` until the first real value lands — + // it just won't ship until `cluster_uuid` is present, which is exactly + // the fail-closed behaviour we want. + context$: cluster$, + schema: { + cluster_uuid: { + type: "keyword", + _meta: { description: "Elasticsearch cluster UUID" }, + }, + cluster_version: { + type: "keyword", + _meta: { description: "Elasticsearch / stack version" }, + }, + }, + }); + + ebt.registerContextProvider({ + name: "license info", + context$: license$, + schema: { + license_id: { + type: "keyword", + _meta: { description: "License id", optional: true }, + }, + license_status: { + type: "keyword", + _meta: { description: "License status", optional: true }, + }, + license_type: { + type: "keyword", + _meta: { description: "License type", optional: true }, + }, + }, + }); + + const mcpApp$ = new BehaviorSubject<{ mcp_app_version: string }>({ + mcp_app_version: opts.mcpAppVersion, + }); + ebt.registerContextProvider({ + name: "mcp app info", + context$: mcpApp$, + schema: { + mcp_app_version: { + type: "keyword", + _meta: { description: "Version of the Elastic Security MCP App" }, + }, + }, + }); + + return { + trackToolCalled(event: McpToolCalledEbtPayload): void { + ebt.reportEvent(EVENT_TYPES.mcpToolCalled, event); + if (optedIn) { + logReportedEvent(logger, sendTo, EVENT_TYPES.mcpToolCalled, event); + } + }, + trackViewRendered(event: ViewRenderedEbtPayload): void { + ebt.reportEvent(EVENT_TYPES.viewRendered, event); + if (optedIn) { + logReportedEvent(logger, sendTo, EVENT_TYPES.viewRendered, event); + } + }, + setOptIn(enabled: boolean): void { + optedIn = enabled; + ebt.optIn({ global: { enabled } }); + }, + setClusterContext(ctx: ClusterContext): void { + cluster$.next(ctx); + }, + setLicenseContext(ctx: LicenseContext): void { + license$.next(ctx); + }, + async shutdown(): Promise { + await ebt.shutdown(); + }, + }; +} diff --git a/src/elastic/analytics/events.ts b/src/elastic/analytics/events.ts new file mode 100644 index 0000000..e9f8a4d --- /dev/null +++ b/src/elastic/analytics/events.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EventTypeOpts } from "@elastic/ebt/client/index.js"; +import type { ViewId } from "../../shared/analytics-events.js"; + +export { VIEW_IDS, type ViewId } from "../../shared/analytics-events.js"; + +/** + * Stable event type identifiers used both when the EBT shipper registers + * the event types at startup and when callers report events. Kept as a + * `const` object so a typo on either end fails to compile. + */ +export const EVENT_TYPES = { + mcpToolCalled: "mcp_tool_called", + viewRendered: "view_rendered", +} as const; + +/** + * EBT payload reported when a model-facing or app-only MCP tool returns. + * + * Server-side shape used by the EBT shipper — distinct from the + * client→server wire format types in `src/shared/analytics-events.ts`. + * Field names are snake_case to match the Kibana telemetry schema. + */ +export interface McpToolCalledEbtPayload { + readonly tool_id: string; + readonly duration_ms: number; + readonly success: boolean; +} + +/** + * EBT payload reported when a React view mounts. + * + * Distinct from the wire-format {@link ViewRenderedEvent} in + * `src/shared/analytics-events.ts`: that one is what the React hook + * sends through the MCP tool (camelCase + `eventType` discriminator), + * this one is what the server ships onward to EBT. + */ +export interface ViewRenderedEbtPayload { + readonly view_id: ViewId; +} + +/** + * EBT schema for `mcp_tool_called`. Kept alongside the TS type so adding + * a field can't drift the two apart. + */ +export const mcpToolCalledEventDef: EventTypeOpts = { + eventType: EVENT_TYPES.mcpToolCalled, + schema: { + tool_id: { + type: "keyword", + _meta: { description: "MCP tool that was invoked" }, + }, + duration_ms: { + type: "long", + _meta: { description: "Wall-clock duration of the tool handler in ms" }, + }, + success: { + type: "boolean", + _meta: { + description: "Whether the handler resolved (true) or threw (false)", + }, + }, + }, +}; + +export const viewRenderedEventDef: EventTypeOpts = { + eventType: EVENT_TYPES.viewRendered, + schema: { + view_id: { + type: "keyword", + _meta: { description: "Identifier of the React view that mounted" }, + }, + }, +}; diff --git a/src/elastic/analytics/index.ts b/src/elastic/analytics/index.ts new file mode 100644 index 0000000..4500766 --- /dev/null +++ b/src/elastic/analytics/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { + AnalyticsClient, + ClusterContext, + LicenseContext, +} from "./analytics-client.js"; +export { noopAnalyticsClient } from "./analytics-client.js"; +export type { + McpToolCalledEbtPayload, + ViewRenderedEbtPayload, +} from "./events.js"; +export { EVENT_TYPES, VIEW_IDS, type ViewId } from "./events.js"; +export { + createAnalyticsClient, + resolveTelemetrySendTo, + type CreateAnalyticsClientOptions, + type TelemetrySendTo, +} from "./create-analytics-client.js"; +export { + createContextLoader, + type ContextLoader, + type CreateContextLoaderDeps, +} from "./context-loader.js"; diff --git a/src/elastic/client/index.ts b/src/elastic/client/index.ts index 05c21d6..89cb126 100644 --- a/src/elastic/client/index.ts +++ b/src/elastic/client/index.ts @@ -74,3 +74,6 @@ export type { DeleteByQueryResponse, } from "./sampleDataClient.js"; export { SampleDataClient } from "./sampleDataClient.js"; + +export type { TelemetryConfig } from "./telemetryConfigClient.js"; +export { TelemetryConfigClient } from "./telemetryConfigClient.js"; diff --git a/src/elastic/client/telemetryConfigClient.test.ts b/src/elastic/client/telemetryConfigClient.test.ts new file mode 100644 index 0000000..0031b10 --- /dev/null +++ b/src/elastic/client/telemetryConfigClient.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { describe, it, expect } from "vitest"; +import { + TelemetryConfigClient, + type TelemetryConfig, +} from "./telemetryConfigClient.js"; +import { + createMockKibanaClient, + dataEnvelope, +} from "../../test/helpers/mockHttpClient.js"; + +const CONFIG_PATH = "/api/telemetry/v2/config"; + +const sampleConfig: TelemetryConfig = { + allowChangingOptInStatus: true, + optIn: true, + sendUsageFrom: "server", + telemetryNotifyUserAboutOptInDefault: false, + labels: {}, +}; + +describe("TelemetryConfigClient", () => { + it("GETs /api/telemetry/v2/config with the elastic-api-version header", async () => { + const kibanaClient = createMockKibanaClient(); + kibanaClient.get.mockResolvedValueOnce(dataEnvelope(sampleConfig)); + + const client = new TelemetryConfigClient({ kibanaClient }); + const out = await client.fetchConfig(); + + expect(kibanaClient.get).toHaveBeenCalledWith(CONFIG_PATH, { + headers: { "elastic-api-version": "2023-10-31" }, + }); + expect(out).toEqual(sampleConfig); + }); + + it("propagates errors verbatim — failures must surface to the service", async () => { + const kibanaClient = createMockKibanaClient(); + const boom = new Error("kibana 503"); + kibanaClient.get.mockRejectedValueOnce(boom); + + const client = new TelemetryConfigClient({ kibanaClient }); + await expect(client.fetchConfig()).rejects.toBe(boom); + }); +}); diff --git a/src/elastic/client/telemetryConfigClient.ts b/src/elastic/client/telemetryConfigClient.ts new file mode 100644 index 0000000..69a6fb2 --- /dev/null +++ b/src/elastic/client/telemetryConfigClient.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { KibanaClient } from "../kibana-client/index.js"; + +const TELEMETRY_CONFIG_PATH = "/api/telemetry/v2/config"; +const KIBANA_API_VERSION = "2023-10-31"; + +const KIBANA_HEADERS = { + "elastic-api-version": KIBANA_API_VERSION, +} as const; + +export interface TelemetryConfig { + readonly allowChangingOptInStatus: boolean; + readonly optIn: boolean | null; + readonly sendUsageFrom: "server" | "browser"; + readonly telemetryNotifyUserAboutOptInDefault: boolean; + readonly labels: Record; +} + +interface TelemetryConfigClientOptions { + readonly kibanaClient: KibanaClient; +} + +export class TelemetryConfigClient { + constructor(private readonly options: TelemetryConfigClientOptions) {} + + async fetchConfig(): Promise { + const { data } = await this.options.kibanaClient.get( + TELEMETRY_CONFIG_PATH, + { headers: KIBANA_HEADERS }, + ); + return data; + } +} diff --git a/src/elastic/service/index.ts b/src/elastic/service/index.ts index 38671ee..93f11e5 100644 --- a/src/elastic/service/index.ts +++ b/src/elastic/service/index.ts @@ -19,3 +19,4 @@ export type { ScenarioRuleDef, } from "./sampleDataService.js"; export { SampleDataService, SCENARIO_NAMES, SCENARIO_RULES } from "./sampleDataService.js"; +export { TelemetryService } from "./telemetryService.js"; diff --git a/src/elastic/service/telemetryService.test.ts b/src/elastic/service/telemetryService.test.ts new file mode 100644 index 0000000..97e3bc3 --- /dev/null +++ b/src/elastic/service/telemetryService.test.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { describe, it, expect, vi } from "vitest"; +import { TelemetryService } from "./telemetryService.js"; +import { createMockAnalyticsClient } from "../../test/helpers/mockAnalytics.js"; +import type { + TelemetryConfig, + TelemetryConfigClient, +} from "../client/telemetryConfigClient.js"; + +function fakeConfigClient( + fetchConfig: () => Promise, +): TelemetryConfigClient { + return { fetchConfig: vi.fn(fetchConfig) } as unknown as TelemetryConfigClient; +} + +function buildConfig(overrides: Partial = {}): TelemetryConfig { + return { + allowChangingOptInStatus: true, + optIn: true, + sendUsageFrom: "server", + telemetryNotifyUserAboutOptInDefault: false, + labels: {}, + ...overrides, + }; +} + +describe("TelemetryService.applyOptIn", () => { + it("enables shipping when Kibana reports optIn === true", async () => { + const analytics = createMockAnalyticsClient(); + const logger = { info: vi.fn(), warn: vi.fn() }; + const service = new TelemetryService({ + telemetryConfigClient: fakeConfigClient(async () => buildConfig({ optIn: true })), + analytics, + logger, + }); + + await service.applyOptIn(); + + expect(analytics.setOptIn).toHaveBeenCalledWith(true); + expect(logger.info).toHaveBeenCalledWith( + "Kibana telemetry opt-in resolved: enabled=true raw=true send_to=production", + ); + }); + + it("disables shipping when Kibana reports optIn === false", async () => { + const analytics = createMockAnalyticsClient(); + const logger = { info: vi.fn(), warn: vi.fn() }; + const service = new TelemetryService({ + telemetryConfigClient: fakeConfigClient(async () => buildConfig({ optIn: false })), + analytics, + logger, + }); + + await service.applyOptIn(); + + expect(analytics.setOptIn).toHaveBeenCalledWith(false); + expect(logger.info).toHaveBeenCalledWith( + "Kibana telemetry opt-in resolved: enabled=false raw=false send_to=production", + ); + }); + + it("treats optIn === null as opted-out (user not prompted yet)", async () => { + const analytics = createMockAnalyticsClient(); + const logger = { info: vi.fn(), warn: vi.fn() }; + const service = new TelemetryService({ + telemetryConfigClient: fakeConfigClient(async () => buildConfig({ optIn: null })), + analytics, + logger, + }); + + await service.applyOptIn(); + + expect(analytics.setOptIn).toHaveBeenCalledWith(false); + expect(logger.info).toHaveBeenCalledWith( + "Kibana telemetry opt-in resolved: enabled=false raw=null send_to=production", + ); + }); + + it("includes the configured telemetry destination in the opt-in log", async () => { + const analytics = createMockAnalyticsClient(); + const logger = { info: vi.fn(), warn: vi.fn() }; + const service = new TelemetryService({ + telemetryConfigClient: fakeConfigClient(async () => buildConfig({ optIn: true })), + analytics, + sendTo: "staging", + logger, + }); + + await service.applyOptIn(); + + expect(logger.info).toHaveBeenCalledWith( + "Kibana telemetry opt-in resolved: enabled=true raw=true send_to=staging", + ); + }); + + it("falls back to opted-out and logs when the config fetch throws", async () => { + const analytics = createMockAnalyticsClient(); + const warn = vi.fn(); + const info = vi.fn(); + const service = new TelemetryService({ + telemetryConfigClient: fakeConfigClient(async () => { + throw new Error("network down"); + }), + analytics, + logger: { info, warn }, + }); + + await service.applyOptIn(); + + expect(analytics.setOptIn).toHaveBeenCalledWith(false); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("network down"), + ); + expect(info).toHaveBeenCalledWith( + "Kibana telemetry opt-in resolved: enabled=false raw=unavailable send_to=production", + ); + }); +}); diff --git a/src/elastic/service/telemetryService.ts b/src/elastic/service/telemetryService.ts new file mode 100644 index 0000000..b689651 --- /dev/null +++ b/src/elastic/service/telemetryService.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AnalyticsClient, TelemetrySendTo } from "../analytics/index.js"; +import { resolveTelemetrySendTo } from "../analytics/index.js"; +import type { TelemetryConfigClient } from "../client/telemetryConfigClient.js"; +import { createStderrLogger, type Logger } from "../../shared/logger.js"; + +interface TelemetryServiceOptions { + readonly telemetryConfigClient: TelemetryConfigClient; + readonly analytics: Pick; + readonly sendTo?: TelemetrySendTo; + readonly logger?: Pick; +} + +export class TelemetryService { + constructor(private readonly options: TelemetryServiceOptions) {} + + async applyOptIn(): Promise { + const { + telemetryConfigClient, + analytics, + sendTo = resolveTelemetrySendTo(process.env.MCP_APP_TELEMETRY_ENV), + logger = createStderrLogger(["telemetry"]), + } = this.options; + + try { + const config = await telemetryConfigClient.fetchConfig(); + const enabled = config.optIn === true; + analytics.setOptIn(enabled); + logger.info( + `Kibana telemetry opt-in resolved: enabled=${enabled} raw=${String( + config.optIn, + )} send_to=${sendTo}`, + ); + } catch (err) { + logger.warn( + `failed to read Kibana telemetry config; staying opted-out: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + analytics.setOptIn(false); + logger.info( + `Kibana telemetry opt-in resolved: enabled=false raw=unavailable send_to=${sendTo}`, + ); + } + } +} diff --git a/src/server.ts b/src/server.ts index deb1a38..f684b05 100644 --- a/src/server.ts +++ b/src/server.ts @@ -35,11 +35,13 @@ import { SampleDataService, } from "./elastic/service/index.js"; import { registerAlertTriageTools } from "./tools/alert-triage.js"; +import { registerAnalyticsTools } from "./tools/analytics.js"; import { registerAttackDiscoveryTools } from "./tools/attack-discovery.js"; import { registerCaseManagementTools } from "./tools/case-management.js"; import { registerDetectionRuleTools } from "./tools/detection-rules.js"; import { registerSampleDataTools } from "./tools/sample-data.js"; import { registerThreatHuntTools } from "./tools/threat-hunt.js"; +import { noopAnalyticsClient, type AnalyticsClient } from "./elastic/analytics/index.js"; export interface CreateServerDeps { /** @@ -50,6 +52,13 @@ export interface CreateServerDeps { * call `createServer()` once. */ readonly credentialClient?: CredentialClient; + /** + * Pre-built analytics client. Must be constructed by `main.ts` so its + * shipper, context providers, and opt-in lifecycle outlive the + * per-request servers in HTTP mode. Wired through to each tool group + * so handlers can emit `mcp_tool_called` via `registerTrackedAppTool`. + */ + readonly analytics?: AnalyticsClient; } /** @@ -63,6 +72,7 @@ export interface CreateServerDeps { export function createServer(deps: CreateServerDeps = {}): McpServer { const credentialClient = deps.credentialClient ?? createCredentialClient(); const credentials = credentialClient.get(); + const analytics = deps.analytics ?? noopAnalyticsClient; const esClient = createEsClient(credentials); const kibanaClient = createKibanaClient(credentials); @@ -101,20 +111,23 @@ export function createServer(deps: CreateServerDeps = {}): McpServer { version: "1.0.0", }); - registerAlertTriageTools(server, { alertsService }); - registerCaseManagementTools(server, { casesService }); - registerDetectionRuleTools(server, { rulesService }); + registerAlertTriageTools(server, { alertsService, analytics }); + registerCaseManagementTools(server, { casesService, analytics }); + registerDetectionRuleTools(server, { rulesService, analytics }); registerThreatHuntTools(server, { esqlService, indicesService, investigateService, entityDetailService, + analytics, }); - registerSampleDataTools(server, { sampleDataService }); + registerSampleDataTools(server, { sampleDataService, analytics }); registerAttackDiscoveryTools(server, { attackDiscoveryService, casesService, + analytics, }); + registerAnalyticsTools(server, { analytics }); return server; } diff --git a/src/shared/analytics-events.ts b/src/shared/analytics-events.ts new file mode 100644 index 0000000..1eff153 --- /dev/null +++ b/src/shared/analytics-events.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const VIEW_IDS = [ + "alert-triage", + "attack-discovery", + "case-management", + "detection-rules", + "sample-data", + "threat-hunt", +] as const; + +export type ViewId = (typeof VIEW_IDS)[number]; + +export type ViewRenderedEvent = { + readonly eventType: "view_rendered"; + readonly viewId: ViewId; +}; + +export type AnalyticsEvent = ViewRenderedEvent; + +export const ANALYTICS_EVENT_TYPES = ["view_rendered"] as const; +export type AnalyticsEventType = (typeof ANALYTICS_EVENT_TYPES)[number]; diff --git a/src/shared/hooks/McpAppContext.ts b/src/shared/hooks/McpAppContext.ts new file mode 100644 index 0000000..4b40934 --- /dev/null +++ b/src/shared/hooks/McpAppContext.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createContext } from "react"; +import type { App as McpApp } from "@modelcontextprotocol/ext-apps"; +import type { McpAppBootstrapState } from "../mcp-app-bootstrap.js"; + +/** Argument shape pushed to `app.ontoolresult`. */ +export type ToolResultParams = Parameters>[0]; + +export type OnToolResult = (params: ToolResultParams, app: McpApp) => void; + +/** Unsubscribe function returned by every `subscribe*` call. */ +export type Unsubscribe = () => void; + +/** + * Internal context value backing ``. + * + * Descendants read the connected `McpApp` (and the `connected` flag) + * via `useMcpApp()`. Components that need to react to lifecycle events + * register listeners via `useMcpAppEvents()`, which routes through the + * `subscribeTo*` functions exposed here. + * + * The provider's pub/sub registry lives in `Set` refs — adding + * or removing listeners never re-fires the provider's connect effect. + * + * **Replay semantics:** + * - `subscribeToToolResult` — no replay. New subscribers see events + * that arrive after they subscribe, never before. + * - `bootstrapState` — persisted in React state. Late subscribers read + * the current bootstrap state synchronously from context, so they + * never miss the host-owned initial hydration payload once it has + * arrived. + */ +export interface McpAppContextValue { + /** The underlying MCP app instance, or null until React mounts. */ + readonly app: McpApp | null; + /** Stable ref accessor — null until connect is set up. */ + readonly getApp: () => McpApp | null; + /** True once `app.connect()` resolves. */ + readonly connected: boolean; + /** Host-owned initial hydration state for this app view. */ + readonly bootstrapState: McpAppBootstrapState; + /** + * Subscribe to every `ontoolresult` push from the host. Returns an + * {@link Unsubscribe} that removes the listener. + */ + readonly subscribeToToolResult: (listener: OnToolResult) => Unsubscribe; +} + +export const McpAppContext = createContext(null); diff --git a/src/shared/hooks/McpAppProvider.tsx b/src/shared/hooks/McpAppProvider.tsx new file mode 100644 index 0000000..8db7292 --- /dev/null +++ b/src/shared/hooks/McpAppProvider.tsx @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { App as McpApp } from "@modelcontextprotocol/ext-apps"; +import { applyTheme } from "../theme"; +import { + type McpAppBootstrapState, + inspectMcpAppBootstrapResult, +} from "../mcp-app-bootstrap"; +import { + McpAppContext, + type McpAppContextValue, + type OnToolResult, + type Unsubscribe, +} from "./McpAppContext"; + +export interface McpAppProviderProps { + /** App identification — passed to `new McpApp({ name, version })`. */ + name: string; + version: string; + children: ReactNode; +} + +/** + * Owns the MCP App lifecycle for a single view subtree. + * + * Constructs the `McpApp`, applies the design-system theme, wires + * `ontoolresult`, calls `connect()`, and tears down on unmount. The + * connected instance plus the `connected` flag are exposed via + * {@link McpAppContext} so descendants (`useMcpApp`, `useAnalytics`, + * `useMcpAppEvents`) can share a single instance instead of each + * constructing their own. + * + * Lifecycle events (`ontoolresult` from the host) are fanned out to all + * registered listeners via the `subscribeToToolResult` function on the + * context value. The provider also persists the host-owned bootstrap + * state in React state so late subscribers can synchronously read the + * initial hydration payload after it arrives. Listener registries live + * in `Set` refs so adding / removing subscribers never re-triggers the + * connect effect. + */ +export function McpAppProvider({ + name, + version, + children, +}: McpAppProviderProps): ReactNode { + const appRef = useRef(null); + const [connected, setConnected] = useState(false); + const [bootstrapState, setBootstrapState] = useState({ + status: "idle", + }); + + // Pub/sub registries. Refs (not state) so adding subscribers never + // re-runs the connect effect. `Set` semantics make subscribe/ + // unsubscribe O(1) and naturally support multiple subscribers per + // event. + const toolResultListeners = useRef>(new Set()); + + useEffect(() => { + const app = new McpApp({ name, version }); + appRef.current = app; + setConnected(false); + setBootstrapState({ status: "idle" }); + applyTheme(app); + + let cancelled = false; + + app.ontoolresult = (params) => { + // Snapshot the listener set so a subscriber that unsubscribes + // during its own callback doesn't perturb the iteration order. + for (const listener of [...toolResultListeners.current]) { + try { + listener(params, app); + } catch (e) { + console.error("onToolResult listener failed:", e); + } + } + + const bootstrapResult = inspectMcpAppBootstrapResult(params); + if (bootstrapResult.status === "not_bootstrap") { + return; + } + if (bootstrapResult.status === "error") { + setBootstrapState((current) => + current.status === "ready" ? current : bootstrapResult, + ); + return; + } + setBootstrapState((current) => { + if (current.status === "ready") { + return current; + } + if (bootstrapResult.envelope.viewId !== name) { + return { + status: "error", + reason: `Received bootstrap for ${bootstrapResult.envelope.viewId} inside ${name}.`, + }; + } + return bootstrapResult; + }); + }; + + app.connect() + .then(() => { + if (cancelled) return; + setConnected(true); + }) + .catch((err) => { + if (cancelled) return; + console.error("MCP app connect() failed:", err); + }); + + return () => { + cancelled = true; + app.close(); + appRef.current = null; + }; + }, [name, version]); + + const subscribeToToolResult = useCallback( + (listener: OnToolResult): Unsubscribe => { + toolResultListeners.current.add(listener); + return () => { + toolResultListeners.current.delete(listener); + }; + }, + [], + ); + + const value = useMemo( + () => ({ + app: appRef.current, + getApp: () => appRef.current, + connected, + bootstrapState, + subscribeToToolResult, + }), + [bootstrapState, connected, subscribeToToolResult], + ); + + return {children}; +} diff --git a/src/shared/hooks/useAnalytics.test.tsx b/src/shared/hooks/useAnalytics.test.tsx new file mode 100644 index 0000000..b409c14 --- /dev/null +++ b/src/shared/hooks/useAnalytics.test.tsx @@ -0,0 +1,184 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useRef, type ReactNode } from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render, waitFor, act } from "@testing-library/react"; +import type { App as McpApp } from "@modelcontextprotocol/ext-apps"; +import { + McpAppContext, + type McpAppContextValue, +} from "./McpAppContext.js"; +import { useAnalytics } from "./useAnalytics.js"; +import type { AnalyticsEvent, ViewId } from "../analytics-events.js"; + +interface FakeApp { + callServerTool: ReturnType; +} + +function makeContext({ + app, + connected, +}: { + app: FakeApp | null; + connected: boolean; +}): McpAppContextValue { + // `useAnalytics` doesn't subscribe to the pub/sub channels, so stubs + // that return a no-op unsubscribe are enough. Tests that exercise the + // channels (multi-subscriber fan-out, replay) live in + // `useMcpAppEvents.test.tsx`. + return { + app: app as unknown as McpApp | null, + getApp: () => app as unknown as McpApp | null, + connected, + bootstrapState: { status: "idle" }, + subscribeToToolResult: () => () => {}, + }; +} + +function Wrapper({ + value, + children, +}: { + value: McpAppContextValue; + children: ReactNode; +}) { + return {children}; +} + +function ProbeOnMount({ viewId }: { viewId: ViewId }) { + const { trackEvent } = useAnalytics(); + useEffect(() => { + trackEvent({ eventType: "view_rendered", viewId }); + }, [trackEvent, viewId]); + return null; +} + +describe("useAnalytics", () => { + it("calls report-analytics-event once when mounted with a ready connection", async () => { + const callServerTool = vi.fn().mockResolvedValue(undefined); + const ctx = makeContext({ app: { callServerTool }, connected: true }); + + render( + + + , + ); + + await waitFor(() => expect(callServerTool).toHaveBeenCalledTimes(1)); + expect(callServerTool).toHaveBeenCalledWith({ + name: "report-analytics-event", + arguments: { eventType: "view_rendered", viewId: "alert-triage" }, + }); + }); + + it("buffers calls issued pre-connect and flushes them when connected flips true", async () => { + const callServerTool = vi.fn().mockResolvedValue(undefined); + + const { rerender } = render( + + + , + ); + + expect(callServerTool).not.toHaveBeenCalled(); + + // ProbeOnMount's `useRef` state must survive the rerender, so we have to + // re-render the *same* component instance with a new context value. + rerender( + + + , + ); + + await waitFor(() => expect(callServerTool).toHaveBeenCalledTimes(1)); + expect(callServerTool).toHaveBeenCalledWith({ + name: "report-analytics-event", + arguments: { eventType: "view_rendered", viewId: "alert-triage" }, + }); + }); + + it("forwards every call — the hook does NOT dedupe, the consumer owns that", async () => { + const callServerTool = vi.fn().mockResolvedValue(undefined); + const ctx = makeContext({ app: { callServerTool }, connected: true }); + + function DoubleFire() { + const { trackEvent } = useAnalytics(); + const ran = useRef(false); + useEffect(() => { + if (!ran.current) { + ran.current = true; + trackEvent({ eventType: "view_rendered", viewId: "threat-hunt" }); + trackEvent({ eventType: "view_rendered", viewId: "threat-hunt" }); + } + }, [trackEvent]); + return null; + } + + render( + + + , + ); + + await waitFor(() => expect(callServerTool).toHaveBeenCalledTimes(2)); + expect(callServerTool).toHaveBeenNthCalledWith(1, { + name: "report-analytics-event", + arguments: { eventType: "view_rendered", viewId: "threat-hunt" }, + }); + expect(callServerTool).toHaveBeenNthCalledWith(2, { + name: "report-analytics-event", + arguments: { eventType: "view_rendered", viewId: "threat-hunt" }, + }); + }); + + it("keeps a stable trackEvent identity across context churn", async () => { + const callServerTool = vi.fn().mockResolvedValue(undefined); + + const identities: Array<(event: AnalyticsEvent) => void> = []; + + function Probe() { + const { trackEvent } = useAnalytics(); + identities.push(trackEvent); + return null; + } + + const { rerender } = render( + + + , + ); + + rerender( + + + , + ); + + expect(identities.length).toBeGreaterThanOrEqual(2); + expect(identities[0]).toBe(identities[identities.length - 1]); + }); + + it("swallows failures from app.callServerTool so views are never broken by telemetry", async () => { + const callServerTool = vi + .fn() + .mockRejectedValue(new Error("transport closed")); + const ctx = makeContext({ app: { callServerTool }, connected: true }); + + render( + + + , + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(callServerTool).toHaveBeenCalled(); + }); +}); diff --git a/src/shared/hooks/useAnalytics.ts b/src/shared/hooks/useAnalytics.ts new file mode 100644 index 0000000..7667062 --- /dev/null +++ b/src/shared/hooks/useAnalytics.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useRef } from "react"; +import type { App as McpApp } from "@modelcontextprotocol/ext-apps"; +import { useMcpApp } from "./useMcpApp"; +import type { AnalyticsEvent, ViewId } from "../analytics-events"; + +export type { AnalyticsEvent, ViewId }; + +export interface UseAnalytics { + trackEvent: (event: AnalyticsEvent) => void; +} + +function dispatch(app: McpApp, event: AnalyticsEvent): void { + void app + .callServerTool({ + name: "report-analytics-event", + arguments: event, + }) + .catch(() => {}); +} + +export function useAnalytics(): UseAnalytics { + const { getApp, connected } = useMcpApp(); + + const pending = useRef([]); + + const getAppRef = useRef(getApp); + getAppRef.current = getApp; + const connectedRef = useRef(connected); + connectedRef.current = connected; + + const trackEvent = useCallback((event: AnalyticsEvent): void => { + if (!connectedRef.current) { + pending.current.push(event); + return; + } + const app = getAppRef.current(); + if (app) dispatch(app, event); + }, []); + + useEffect(() => { + if (!connected || pending.current.length === 0) return; + const app = getApp(); + if (!app) return; + const buffered = pending.current.splice(0, pending.current.length); + for (const event of buffered) dispatch(app, event); + }, [connected, getApp]); + + return { trackEvent }; +} diff --git a/src/shared/hooks/useMcpApp.ts b/src/shared/hooks/useMcpApp.ts index 917a483..766d477 100644 --- a/src/shared/hooks/useMcpApp.ts +++ b/src/shared/hooks/useMcpApp.ts @@ -5,107 +5,113 @@ * 2.0. */ -import { useEffect, useRef, useState } from "react"; -import { App as McpApp } from "@modelcontextprotocol/ext-apps"; -import { applyTheme } from "../theme"; +import { useContext, useEffect, useMemo, useRef } from "react"; +import type { App as McpApp } from "@modelcontextprotocol/ext-apps"; +import type { + McpAppBootstrapEnvelope, + McpAppBootstrapErrorState, + McpAppBootstrapIdleState, + ViewBootstrapPayloads, +} from "../mcp-app-bootstrap.js"; +import type { ViewId } from "../analytics-events.js"; +import { + McpAppContext, + type McpAppContextValue, + type OnToolResult, +} from "./McpAppContext"; -type ToolResultParams = Parameters>[0]; +export type { OnToolResult } from "./McpAppContext"; -export interface UseMcpAppOptions { - /** App identification — passed to `new McpApp({ name, version })`. */ - name: string; - version: string; - /** - * Called when the host pushes an initial tool result. Use this to seed - * params/state from `params._meta` etc. before falling through to the - * view's own loader. - */ - onToolResult?: (params: ToolResultParams, app: McpApp) => void; - /** - * Called once the connection is up. Receives whether `onToolResult` already - * fired (via the 1500ms grace timer) so the view can decide to issue its - * own initial fetch when nothing was pushed. - */ - onConnect?: (app: McpApp, gotResult: boolean) => void; +export interface UseMcpAppState { + readonly app: McpApp | null; + readonly getApp: () => McpApp | null; + readonly connected: boolean; + readonly bootstrapState: McpAppContextValue["bootstrapState"]; } -export interface UseMcpAppState { - /** The underlying MCP app instance, or null until React mounts. */ - app: McpApp | null; - /** Stable ref accessor — useful inside `useCallback`s where the value can be null until connect. */ - getApp: () => McpApp | null; - /** True once `app.connect()` resolves. Views typically gate their UI on this. */ - connected: boolean; +export function useMcpApp(): UseMcpAppState { + const ctx = useContext(McpAppContext); + if (!ctx) { + throw new Error( + "useMcpApp() must be used inside . Wrap your view's root in .", + ); + } + return { + app: ctx.app, + getApp: ctx.getApp, + connected: ctx.connected, + bootstrapState: ctx.bootstrapState, + }; } -/** - * Standard MCP app bootstrap: instantiate, apply the design-system theme, - * register the `ontoolresult` handler, connect, and tear down on unmount. - * - * Replaces the ~30-line copy-pasted `useEffect` block at the top of every - * view's `App.tsx`. - */ -export function useMcpApp({ name, version, onToolResult, onConnect }: UseMcpAppOptions): UseMcpAppState { - const appRef = useRef(null); - const [connected, setConnected] = useState(false); - // Refs stash the latest callbacks so the connect effect runs once, even - // when consumers pass new function identities on every render. - const onToolResultRef = useRef(onToolResult); - const onConnectRef = useRef(onConnect); - onToolResultRef.current = onToolResult; - onConnectRef.current = onConnect; +export interface UseMcpAppEventsOptions { + /** + * Called whenever the host pushes a tool result after transport + * connection. Startup bootstrap payloads are delivered on this same + * channel, so consumers that rely on `useMcpAppBootstrap()` should + * ignore the explicit bootstrap envelope here. + */ + onToolResult?: OnToolResult; +} - useEffect(() => { - const app = new McpApp({ name, version }); - appRef.current = app; - applyTheme(app); +export function useMcpAppEvents(options: UseMcpAppEventsOptions): void { + const ctx = useContext(McpAppContext); + if (!ctx) { + throw new Error( + "useMcpAppEvents() must be used inside .", + ); + } - let gotResult = false; - let cancelled = false; - let graceTimer: ReturnType | null = null; + const onToolResultRef = useRef(options.onToolResult); + onToolResultRef.current = options.onToolResult; - app.ontoolresult = (params) => { - gotResult = true; - try { - onToolResultRef.current?.(params, app); - } catch (e) { - console.error("ontoolresult handler failed:", e); - } + useEffect(() => { + const unsubscribeToolResult = ctx.subscribeToToolResult((params, app) => { + onToolResultRef.current?.(params, app); + }); + return () => { + unsubscribeToolResult(); }; + }, [ctx]); +} - app.connect() - .then(() => { - if (cancelled) return; - setConnected(true); - // Give the host a 1.5s grace period to push a tool result before - // falling back to whatever the view's own loader wants to do. - graceTimer = setTimeout(() => { - graceTimer = null; - if (cancelled) return; - onConnectRef.current?.(app, gotResult); - }, 1500); - }) - .catch((err) => { - // If the host never connects we surface the error and leave - // `connected` false so views can keep their own fallback UI in place - // instead of being stuck on the "Connecting…" placeholder. - if (cancelled) return; - console.error("MCP app connect() failed:", err); - }); +type ViewBootstrapReadyState = { + readonly status: "ready"; + readonly envelope: McpAppBootstrapEnvelope; + readonly payload: ViewBootstrapPayloads[V]; +}; - return () => { - cancelled = true; - if (graceTimer !== null) { - clearTimeout(graceTimer); - graceTimer = null; - } - app.close(); - }; - }, [name, version]); +export type UseMcpAppBootstrapState = + | McpAppBootstrapIdleState + | McpAppBootstrapErrorState + | ViewBootstrapReadyState; - return { - app: appRef.current, - getApp: () => appRef.current, - connected, - }; +/** + * Read the host-owned startup payload for a specific view. + * + * The provider persists bootstrap state in context, so late subscribers + * do not need a separate replay subscription: they synchronously read + * the current state on mount. + */ +export function useMcpAppBootstrap( + viewId: V, +): UseMcpAppBootstrapState { + const { bootstrapState } = useMcpApp(); + return useMemo(() => { + if (bootstrapState.status !== "ready") { + return bootstrapState; + } + if (bootstrapState.envelope.viewId !== viewId) { + return { + status: "error", + reason: `Bootstrap for ${bootstrapState.envelope.viewId} does not match ${viewId}.`, + }; + } + return { + status: "ready", + // The runtime check above narrows the envelope to the requested view. + envelope: bootstrapState.envelope as McpAppBootstrapEnvelope, + payload: bootstrapState.envelope.payload as ViewBootstrapPayloads[V], + }; + }, [bootstrapState, viewId]); } diff --git a/src/shared/hooks/useMcpAppEvents.test.tsx b/src/shared/hooks/useMcpAppEvents.test.tsx new file mode 100644 index 0000000..b60031d --- /dev/null +++ b/src/shared/hooks/useMcpAppEvents.test.tsx @@ -0,0 +1,290 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useState, type ReactNode } from "react"; +import { describe, it, expect, vi } from "vitest"; +import { render, act } from "@testing-library/react"; +import type { App as McpApp } from "@modelcontextprotocol/ext-apps"; +import { + McpAppContext, + type McpAppContextValue, + type OnToolResult, + type ToolResultParams, +} from "./McpAppContext.js"; +import { createMcpAppBootstrap } from "../mcp-app-bootstrap.js"; +import { useMcpAppBootstrap, useMcpAppEvents } from "./useMcpApp.js"; + +/** + * Build a controllable context value that mirrors `McpAppProvider`'s + * pub/sub registry without dragging in `new McpApp()` or a real + * transport. Exposes `triggerToolResult` so tests can drive events + * deterministically, while bootstrap state is provided directly on the + * context value the same way late subscribers would read it in the app. + */ +function buildTestContext({ + appStub, + connected = true, + bootstrapState = { status: "idle" } as const, +}: { + appStub: McpApp; + connected?: boolean; + bootstrapState?: McpAppContextValue["bootstrapState"]; +}) { + const toolResultListeners = new Set(); + + const ctx: McpAppContextValue = { + app: appStub, + getApp: () => appStub, + connected, + bootstrapState, + subscribeToToolResult: (listener) => { + toolResultListeners.add(listener); + return () => { + toolResultListeners.delete(listener); + }; + }, + }; + + return { + ctx, + triggerToolResult(params: ToolResultParams) { + for (const l of [...toolResultListeners]) l(params, appStub); + }, + toolResultCount: () => toolResultListeners.size, + }; +} + +function Wrapper({ + value, + children, +}: { + value: McpAppContextValue; + children: ReactNode; +}) { + return {children}; +} + +const stubApp = {} as unknown as McpApp; +const fakeToolResult = {} as unknown as ToolResultParams; + +describe("useMcpAppEvents", () => { + it("fans onToolResult out to every subscriber in the tree", () => { + const { ctx, triggerToolResult } = buildTestContext({ appStub: stubApp }); + + const a = vi.fn(); + const b = vi.fn(); + const c = vi.fn(); + + function Probe({ cb }: { cb: OnToolResult }) { + useMcpAppEvents({ onToolResult: cb }); + return null; + } + + render( + + + + + , + ); + + act(() => { + triggerToolResult(fakeToolResult); + }); + + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + expect(c).toHaveBeenCalledTimes(1); + }); + + it("reads the ready bootstrap payload synchronously for late subscribers", () => { + const { ctx } = buildTestContext({ + appStub: stubApp, + bootstrapState: { + status: "ready", + envelope: createMcpAppBootstrap("alert-triage", { + summary: { + total: 1, + bySeverity: { high: 1 }, + byRule: [], + byHost: [], + alerts: [], + }, + params: { days: 7, limit: 50 }, + verdicts: [], + }), + }, + }); + + function LateProbe() { + const bootstrap = useMcpAppBootstrap("alert-triage"); + return
{bootstrap.status === "ready" ? String(bootstrap.payload.summary.total) : "nope"}
; + } + + const { getByText } = render( + + + , + ); + + expect(getByText("1")).toBeTruthy(); + }); + + it("keeps the ready bootstrap object stable across unrelated rerenders", () => { + const { ctx } = buildTestContext({ + appStub: stubApp, + bootstrapState: { + status: "ready", + envelope: createMcpAppBootstrap("threat-hunt", { + indexCount: 1, + indices: ["logs-*"], + params: { query: "FROM logs-* | LIMIT 1" }, + queryResult: { + columns: ["host.name"], + rows: [["win-dc-01"]], + rowCount: 1, + }, + }), + }, + }); + + const seenBootstrapObjects: object[] = []; + + function Probe({ tick }: { tick: number }) { + const bootstrap = useMcpAppBootstrap("threat-hunt"); + seenBootstrapObjects.push(bootstrap); + return
{tick}
; + } + + const { rerender } = render( + + + , + ); + + rerender( + + + , + ); + + // Views such as threat-hunt put `bootstrap` in an effect dependency list. + // Returning a fresh ready object every render causes those effects to replay + // the same bootstrap query and call `execute-esql` repeatedly. + expect(seenBootstrapObjects[seenBootstrapObjects.length - 1]).toBe( + seenBootstrapObjects[0], + ); + }); + + it("surfaces a bootstrap mismatch as an error state", () => { + const { ctx } = buildTestContext({ + appStub: stubApp, + bootstrapState: { + status: "ready", + envelope: createMcpAppBootstrap("case-management", { + total: 0, + cases: [], + params: {}, + }), + }, + }); + + function Probe() { + const bootstrap = useMcpAppBootstrap("alert-triage"); + return
{bootstrap.status === "error" ? bootstrap.reason : "ok"}
; + } + + const { getByText } = render( + + + , + ); + + expect(getByText(/does not match alert-triage/)).toBeTruthy(); + }); + + it("uses the latest callback identity without re-subscribing on every render", () => { + const { ctx, triggerToolResult, toolResultCount } = buildTestContext({ + appStub: stubApp, + }); + + const calls: number[] = []; + + function Probe() { + const [n, setN] = useState(0); + const onToolResult = useCallback(() => { + calls.push(n); + }, [n]); + useMcpAppEvents({ onToolResult }); + return ( + + ); + } + + const { container } = render( + + + , + ); + + expect(toolResultCount()).toBe(1); + + act(() => { + triggerToolResult(fakeToolResult); + }); + expect(calls).toEqual([0]); + + act(() => { + container.querySelector("button")!.click(); + }); + act(() => { + triggerToolResult(fakeToolResult); + }); + + expect(toolResultCount()).toBe(1); + expect(calls).toEqual([0, 1]); + }); + + it("unsubscribes on unmount", () => { + const { ctx, toolResultCount } = buildTestContext({ + appStub: stubApp, + }); + + function Probe() { + useMcpAppEvents({ onToolResult: () => {} }); + return null; + } + + const { unmount } = render( + + + , + ); + + expect(toolResultCount()).toBe(1); + + unmount(); + + expect(toolResultCount()).toBe(0); + }); + + it("throws a clear error when used outside ", () => { + function Probe() { + useMcpAppEvents({}); + return null; + } + + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + expect(() => render()).toThrow(/McpAppProvider/); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/src/shared/logger.test.ts b/src/shared/logger.test.ts new file mode 100644 index 0000000..f5021a5 --- /dev/null +++ b/src/shared/logger.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createStderrLogger } from "./logger.js"; + +describe("createStderrLogger", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("writes child logger messages to stderr with context", () => { + const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + createStderrLogger(["elastic-security"]).child("telemetry").info("opted in"); + + expect(write).toHaveBeenCalledWith("[elastic-security:telemetry] opted in\n"); + }); + + it("formats errors with their stack when available", () => { + const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const err = new Error("boom"); + err.stack = "Error: boom\n at test"; + + createStderrLogger(["server"]).error(err); + + expect(write).toHaveBeenCalledWith("[server] Error: boom\n at test\n"); + }); +}); diff --git a/src/shared/logger.ts b/src/shared/logger.ts new file mode 100644 index 0000000..7c8bbd5 --- /dev/null +++ b/src/shared/logger.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type LogMessage = string | (() => string) | Error; + +export interface Logger { + debug(message: LogMessage): void; + info(message: LogMessage): void; + warn(message: LogMessage): void; + error(message: LogMessage): void; + child(context: string): Logger; + get(...context: string[]): Logger; +} + +function messageToString(message: LogMessage): string { + if (message instanceof Error) { + return message.stack ?? message.message; + } + return typeof message === "function" ? message() : message; +} + +function formatLine(context: readonly string[], message: LogMessage): string { + const prefix = context.length > 0 ? `[${context.join(":")}] ` : ""; + return `${prefix}${messageToString(message)}\n`; +} + +export function createStderrLogger(context: readonly string[] = []): Logger { + return { + debug(message): void { + process.stderr.write(formatLine(context, message)); + }, + info(message): void { + process.stderr.write(formatLine(context, message)); + }, + warn(message): void { + process.stderr.write(formatLine(context, message)); + }, + error(message): void { + process.stderr.write(formatLine(context, message)); + }, + child(childContext): Logger { + return createStderrLogger([...context, childContext]); + }, + get(...childContext): Logger { + return createStderrLogger([...context, ...childContext]); + }, + }; +} diff --git a/src/shared/mcp-app-bootstrap.test.ts b/src/shared/mcp-app-bootstrap.test.ts new file mode 100644 index 0000000..8bf86a4 --- /dev/null +++ b/src/shared/mcp-app-bootstrap.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { describe, expect, it } from "vitest"; +import { + createMcpAppBootstrap, + inspectMcpAppBootstrapResult, + type SampleDataBootstrapPayload, +} from "./mcp-app-bootstrap.js"; + +describe("inspectMcpAppBootstrapResult", () => { + it("ignores ordinary tool results", () => { + const result = inspectMcpAppBootstrapResult({ + content: [{ type: "text", text: JSON.stringify({ ok: true }) }], + }); + expect(result).toEqual({ status: "not_bootstrap" }); + }); + + it("returns a ready state for a valid bootstrap envelope", () => { + const result = inspectMcpAppBootstrapResult({ + content: [ + { + type: "text", + text: JSON.stringify( + createMcpAppBootstrap("sample-data", { + scenarios: ["ransomware-kill-chain"], + existingData: { + totalDocs: 1, + totalAlerts: 2, + existingRules: 3, + byScenario: {}, + }, + }), + ), + }, + ], + }); + expect(result.status).toBe("ready"); + if (result.status !== "ready") { + throw new Error("Expected ready bootstrap result"); + } + expect(result.envelope.viewId).toBe("sample-data"); + if (result.envelope.viewId !== "sample-data") { + throw new Error("Expected sample-data bootstrap"); + } + expect( + (result.envelope.payload as SampleDataBootstrapPayload).existingData.totalDocs, + ).toBe(1); + }); + + it("surfaces an invalid viewId as an error", () => { + const result = inspectMcpAppBootstrapResult({ + content: [ + { + type: "text", + text: JSON.stringify({ + kind: "mcp_app_bootstrap", + viewId: "bogus", + payload: {}, + }), + }, + ], + }); + expect(result.status).toBe("error"); + if (result.status !== "error") { + throw new Error("Expected bootstrap error"); + } + expect(result.reason).toContain("viewId"); + }); + + it("surfaces a missing payload as an error", () => { + const result = inspectMcpAppBootstrapResult({ + content: [ + { + type: "text", + text: JSON.stringify({ + kind: "mcp_app_bootstrap", + viewId: "alert-triage", + }), + }, + ], + }); + expect(result.status).toBe("error"); + if (result.status !== "error") { + throw new Error("Expected bootstrap error"); + } + expect(result.reason).toContain("missing its payload body"); + }); +}); diff --git a/src/shared/mcp-app-bootstrap.ts b/src/shared/mcp-app-bootstrap.ts new file mode 100644 index 0000000..dfafc94 --- /dev/null +++ b/src/shared/mcp-app-bootstrap.ts @@ -0,0 +1,267 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + AttackDiscoveryFinding, +} from "./types.js"; +import { VIEW_IDS, type ViewId } from "./analytics-events.js"; +import { extractToolText } from "./extract-tool-text.js"; + +export const MCP_APP_BOOTSTRAP_KIND = "mcp_app_bootstrap"; + +export interface AlertTriageVerdict { + readonly rule: string; + readonly classification: "benign" | "suspicious" | "malicious"; + readonly confidence: "low" | "medium" | "high"; + readonly summary: string; + readonly action: string; + readonly hosts?: readonly string[]; +} + +export interface AlertTriageBootstrapPayload { + readonly summary: { + readonly total: number; + readonly bySeverity: Record; + readonly byRule: readonly { readonly name: string; readonly count: number }[]; + readonly byHost: readonly { readonly name: string; readonly count: number }[]; + readonly alerts: readonly { + readonly id: string; + readonly rule?: string; + readonly severity?: string; + readonly risk_score?: number; + readonly reason?: string; + readonly host?: string; + readonly user?: string; + readonly process?: string; + readonly executable?: string; + readonly parent_process?: string; + readonly file?: string; + readonly source_ip?: string; + readonly dest_ip?: string; + readonly timestamp?: string; + readonly mitre?: readonly { + readonly tactic: string; + readonly techniques: readonly string[]; + }[]; + }[]; + }; + readonly params: { + readonly days: number; + readonly severity?: string; + readonly limit: number; + readonly query?: string; + }; + readonly verdicts: readonly AlertTriageVerdict[]; +} + +export interface CaseManagementBootstrapPayload { + readonly total: number; + readonly cases: readonly { + readonly id: string; + readonly title: string; + readonly status: string; + readonly severity: string; + readonly totalAlerts?: number; + readonly totalComment?: number; + readonly tags?: readonly string[]; + readonly description?: string; + readonly created_at?: string; + readonly updated_at?: string; + readonly created_by?: string; + }[]; + readonly params: { + readonly status?: string; + readonly severity?: string; + readonly search?: string; + }; +} + +export interface DetectionRulesBootstrapPayload { + readonly total: number; + readonly rules: readonly { + readonly id: string; + readonly name: string; + readonly type?: string; + readonly severity?: string; + readonly enabled?: boolean; + readonly risk_score?: number; + readonly description?: string; + readonly query?: string; + readonly language?: string; + readonly tags?: readonly string[]; + readonly threat?: readonly { + readonly tactic?: string; + readonly techniques: readonly string[]; + }[]; + }[]; + readonly params: { + readonly filter?: string; + readonly page?: number; + readonly perPage?: number; + }; +} + +export interface AttackDiscoveryBootstrapPayload { + readonly total: number; + readonly discoveries: readonly (Pick< + AttackDiscoveryFinding, + | "id" + | "title" + | "summaryMarkdown" + | "detailsMarkdown" + | "mitreTactics" + | "alertIds" + | "alertCount" + | "alertsContextCount" + | "riskScore" + | "timestamp" + | "confidence" + | "hosts" + | "users" + | "ruleNames" + | "signals" + >)[]; + readonly params: { + readonly days: number; + readonly limit: number; + }; +} + +export interface SampleDataExistingData { + readonly totalDocs: number; + readonly totalAlerts: number; + readonly existingRules: number; + readonly byScenario: Record; +} + +export interface SampleDataBootstrapPayload { + readonly scenarios: readonly string[]; + readonly existingData: SampleDataExistingData; +} + +export interface ThreatHuntEntityRef { + readonly type: "user" | "host" | "ip" | "process"; + readonly value: string; +} + +export interface ThreatHuntBootstrapPayload { + readonly indexCount: number; + readonly indices: readonly string[]; + readonly params: { + readonly query?: string; + readonly description?: string; + readonly entity?: ThreatHuntEntityRef; + }; + readonly queryResult?: { + readonly columns: readonly string[]; + readonly rows: readonly (readonly (string | number | boolean | null)[])[]; + readonly rowCount: number; + }; + readonly queryError?: string; + readonly entityGraph?: { + readonly nodeCount: number; + readonly edgeCount: number; + }; +} + +export interface ViewBootstrapPayloads { + "alert-triage": AlertTriageBootstrapPayload; + "attack-discovery": AttackDiscoveryBootstrapPayload; + "case-management": CaseManagementBootstrapPayload; + "detection-rules": DetectionRulesBootstrapPayload; + "sample-data": SampleDataBootstrapPayload; + "threat-hunt": ThreatHuntBootstrapPayload; +} + +export interface McpAppBootstrapEnvelope { + readonly kind: typeof MCP_APP_BOOTSTRAP_KIND; + readonly viewId: V; + readonly payload: ViewBootstrapPayloads[V]; +} + +export interface McpAppBootstrapIdleState { + readonly status: "idle"; +} + +export interface McpAppBootstrapErrorState { + readonly status: "error"; + readonly reason: string; + readonly rawText?: string; +} + +export interface McpAppBootstrapReadyState { + readonly status: "ready"; + readonly envelope: McpAppBootstrapEnvelope; +} + +export type McpAppBootstrapState = + | McpAppBootstrapIdleState + | McpAppBootstrapErrorState + | McpAppBootstrapReadyState; + +export type InspectBootstrapResult = + | { readonly status: "not_bootstrap" } + | McpAppBootstrapErrorState + | McpAppBootstrapReadyState; + +export function createMcpAppBootstrap( + viewId: V, + payload: ViewBootstrapPayloads[V], +): McpAppBootstrapEnvelope { + return { + kind: MCP_APP_BOOTSTRAP_KIND, + viewId, + payload, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isViewId(value: unknown): value is ViewId { + return typeof value === "string" && VIEW_IDS.includes(value as ViewId); +} + +export function inspectMcpAppBootstrapResult(result: unknown): InspectBootstrapResult { + const text = extractToolText(result); + if (!text) { + return { status: "not_bootstrap" }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { status: "not_bootstrap" }; + } + + if (!isRecord(parsed) || parsed.kind !== MCP_APP_BOOTSTRAP_KIND) { + return { status: "not_bootstrap" }; + } + + if (!isViewId(parsed.viewId)) { + return { + status: "error", + reason: "Bootstrap payload is missing a valid viewId.", + rawText: text, + }; + } + + if (!("payload" in parsed)) { + return { + status: "error", + reason: `Bootstrap payload for ${parsed.viewId} is missing its payload body.`, + rawText: text, + }; + } + + return { + status: "ready", + envelope: parsed as unknown as McpAppBootstrapEnvelope, + }; +} diff --git a/src/shared/package-version.test.ts b/src/shared/package-version.test.ts new file mode 100644 index 0000000..4bb7911 --- /dev/null +++ b/src/shared/package-version.test.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { readPackageVersion } from "./package-version.js"; + +function moduleUrlFor(dir: string): string { + return pathToFileURL(join(dir, "fake-module.js")).href; +} + +describe("readPackageVersion", () => { + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "pkg-version-")); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it("returns the version from a package.json next to the calling module", () => { + writeFileSync( + join(tmp, "package.json"), + JSON.stringify({ version: "1.2.3" }), + ); + + expect(readPackageVersion(moduleUrlFor(tmp))).toBe("1.2.3"); + }); + + it("falls back to the parent directory's package.json (the dist/ + .mcpb layout)", () => { + const child = join(tmp, "dist"); + mkdirSync(child); + writeFileSync( + join(tmp, "package.json"), + JSON.stringify({ version: "2.0.0" }), + ); + + expect(readPackageVersion(moduleUrlFor(child))).toBe("2.0.0"); + }); + + it("prefers the closer package.json over the parent's", () => { + const child = join(tmp, "nested"); + mkdirSync(child); + writeFileSync( + join(tmp, "package.json"), + JSON.stringify({ version: "outer" }), + ); + writeFileSync( + join(child, "package.json"), + JSON.stringify({ version: "inner" }), + ); + + expect(readPackageVersion(moduleUrlFor(child))).toBe("inner"); + }); + + it("returns the default '0.0.0' fallback when no package.json is reachable", () => { + expect(readPackageVersion(moduleUrlFor(tmp))).toBe("0.0.0"); + }); + + it("honours a custom fallback when no package.json is reachable", () => { + expect(readPackageVersion(moduleUrlFor(tmp), "9.9.9")).toBe("9.9.9"); + }); + + it("falls back when the nearest package.json is malformed JSON", () => { + writeFileSync(join(tmp, "package.json"), "{not json"); + + expect(readPackageVersion(moduleUrlFor(tmp), "fb")).toBe("fb"); + }); + + it("falls back when package.json has no `version` field", () => { + writeFileSync( + join(tmp, "package.json"), + JSON.stringify({ name: "foo" }), + ); + + expect(readPackageVersion(moduleUrlFor(tmp), "fb")).toBe("fb"); + }); + + it("falls back when `version` is an empty string", () => { + writeFileSync( + join(tmp, "package.json"), + JSON.stringify({ version: "" }), + ); + + expect(readPackageVersion(moduleUrlFor(tmp), "fb")).toBe("fb"); + }); + + it("skips a malformed package.json in startDir and reads the parent's", () => { + const child = join(tmp, "dist"); + mkdirSync(child); + writeFileSync(join(child, "package.json"), "{not json"); + writeFileSync( + join(tmp, "package.json"), + JSON.stringify({ version: "from-parent" }), + ); + + expect(readPackageVersion(moduleUrlFor(child))).toBe("from-parent"); + }); + + it("does not throw when moduleUrl is not a valid file:// URL", () => { + expect(() => readPackageVersion("not a url at all")).not.toThrow(); + expect(readPackageVersion("not a url at all", "fb")).toBe("fb"); + }); +}); diff --git a/src/shared/package-version.ts b/src/shared/package-version.ts new file mode 100644 index 0000000..20c523e --- /dev/null +++ b/src/shared/package-version.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export function readPackageVersion( + moduleUrl: string, + fallback = "0.0.0", +): string { + let here: string; + try { + here = dirname(fileURLToPath(moduleUrl)); + } catch { + return fallback; + } + + for (const candidate of [ + join(here, "package.json"), + join(here, "..", "package.json"), + ]) { + try { + const raw = readFileSync(candidate, "utf8"); + const parsed = JSON.parse(raw) as { version?: string }; + if (parsed.version) return parsed.version; + } catch {} + } + + return fallback; +} diff --git a/src/test/helpers/mockAnalytics.ts b/src/test/helpers/mockAnalytics.ts new file mode 100644 index 0000000..3a95acb --- /dev/null +++ b/src/test/helpers/mockAnalytics.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { vi } from "vitest"; +import type { AnalyticsClient } from "../../elastic/analytics/index.js"; + +/** + * No-op {@link AnalyticsClient} for tests and one-off scripts. Every + * method is a `vi.fn()` so call sites can assert on invocations when + * needed; methods that return `Promise` resolve immediately. + */ +export function createMockAnalyticsClient(): AnalyticsClient { + return { + trackToolCalled: vi.fn(), + trackViewRendered: vi.fn(), + setOptIn: vi.fn(), + setClusterContext: vi.fn(), + setLicenseContext: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + }; +} + +/** + * Lightweight noop variant used in places where call assertions are + * unnecessary (e.g. wiring smoke tests). Every method is a no-op. + */ +export const noopAnalyticsClient: AnalyticsClient = { + trackToolCalled: () => {}, + trackViewRendered: () => {}, + setOptIn: () => {}, + setClusterContext: () => {}, + setLicenseContext: () => {}, + shutdown: async () => {}, +}; diff --git a/src/test/helpers/mockMcpServer.ts b/src/test/helpers/mockMcpServer.ts index 422fc27..959712b 100644 --- a/src/test/helpers/mockMcpServer.ts +++ b/src/test/helpers/mockMcpServer.ts @@ -7,6 +7,12 @@ import { vi, type Mock } from "vitest"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + MCP_APP_BOOTSTRAP_KIND, + type McpAppBootstrapEnvelope, + type ViewBootstrapPayloads, +} from "../../shared/mcp-app-bootstrap.js"; +import type { ViewId } from "../../shared/analytics-events.js"; /** * Captured tool registration. The third argument of `registerTool` is the @@ -129,3 +135,23 @@ export function parseToolText(result: { } return JSON.parse(result.content[0].text) as T; } + +export function parseBootstrapToolText( + result: { + content: { type: "text"; text: string }[]; + }, + viewId: V, +): ViewBootstrapPayloads[V] { + const envelope = parseToolText>(result); + if (envelope.kind !== MCP_APP_BOOTSTRAP_KIND) { + throw new Error( + `Expected bootstrap envelope, got kind ${String(envelope.kind)}`, + ); + } + if (envelope.viewId !== viewId) { + throw new Error( + `Expected bootstrap for ${viewId}, got ${String(envelope.viewId)}`, + ); + } + return envelope.payload as ViewBootstrapPayloads[V]; +} diff --git a/src/test/integration/server.integration.test.ts b/src/test/integration/server.integration.test.ts index eb26b9e..274233d 100644 --- a/src/test/integration/server.integration.test.ts +++ b/src/test/integration/server.integration.test.ts @@ -19,6 +19,7 @@ import fs from "fs"; import { createServer } from "../../server.js"; import { MockAxios } from "../helpers/mockAxios.js"; import { connectInProcess } from "../helpers/integrationServer.js"; +import { noopAnalyticsClient } from "../helpers/mockAnalytics.js"; const ES_BASE_URL = "https://es.example.com"; const KIBANA_BASE_URL = "https://kb.example.com"; @@ -78,7 +79,7 @@ describe("MCP server integration (in-process Client + Server)", () => { /** Boot a fresh in-process server / client pair for one test. */ function bootHarness() { - return connectInProcess(createServer()); + return connectInProcess(createServer({ analytics: noopAnalyticsClient })); } it("advertises every registered tool over `tools/list`", async () => { @@ -139,6 +140,8 @@ describe("MCP server integration (in-process Client + Server)", () => { "generate-attack-discovery", "get-generation-status", "list-ai-connectors", + // analytics + "report-analytics-event", ].sort() ); } finally { @@ -251,14 +254,18 @@ describe("MCP server integration (in-process Client + Server)", () => { expect(result.isError).toBeFalsy(); const content = result.content as { type: "text"; text: string }[]; const body = JSON.parse(content[0].text) as { - total: number; - alerts: { id: string; rule: string }[]; - bySeverity: Record; - params: { severity: string }; + payload: { + summary: { + total: number; + alerts: { _id: string; _source: { "kibana.alert.rule.name": string } }[]; + bySeverity: Record; + }; + params: { severity: string }; + }; }; - expect(body.total).toBe(2); - expect(body.bySeverity).toEqual({ high: 1, critical: 1 }); - expect(body.alerts).toEqual([ + expect(body.payload.summary.total).toBe(2); + expect(body.payload.summary.bySeverity).toEqual({ high: 1, critical: 1 }); + expect(body.payload.summary.alerts).toEqual([ expect.objectContaining({ id: "alert-1", rule: "Suspicious PowerShell", @@ -268,7 +275,7 @@ describe("MCP server integration (in-process Client + Server)", () => { rule: "LSASS dump", }), ]); - expect(body.params.severity).toBe("high"); + expect(body.payload.params.severity).toBe("high"); const calls = mockAxios.history(); expect(calls).toHaveLength(1); @@ -323,12 +330,14 @@ describe("MCP server integration (in-process Client + Server)", () => { const content = result.content as { type: "text"; text: string }[]; const body = JSON.parse(content[0].text) as { - total: number; - rules: { id: string; name: string }[]; - params: { page: number; perPage: number }; + payload: { + total: number; + rules: { id: string; name: string }[]; + params: { page: number; perPage: number }; + }; }; - expect(body.total).toBe(1); - expect(body.rules[0]).toMatchObject({ + expect(body.payload.total).toBe(1); + expect(body.payload.rules[0]).toMatchObject({ id: "r-1", name: "Suspicious PowerShell", }); @@ -617,25 +626,31 @@ describe("MCP server integration (in-process Client + Server)", () => { } } + it("boots successfully when analytics is omitted (defaults to noop)", () => { + expect(() => createServer({})).not.toThrow(); + }); + it("crashes with a clear error when no cluster config is set", () => { withClustersJson(undefined, () => { - expect(() => createServer()).toThrowError(/No clusters configured/); + expect(() => + createServer({ analytics: noopAnalyticsClient }) + ).toThrowError(/No clusters configured/); }); }); it("crashes when CLUSTERS_JSON is an empty array", () => { withClustersJson("[]", () => { - expect(() => createServer()).toThrowError( - /at least one cluster is required/ - ); + expect(() => + createServer({ analytics: noopAnalyticsClient }) + ).toThrowError(/at least one cluster is required/); }); }); it("crashes when CLUSTERS_JSON is malformed JSON", () => { withClustersJson("{not json", () => { - expect(() => createServer()).toThrowError( - /CLUSTERS_JSON: invalid JSON/ - ); + expect(() => + createServer({ analytics: noopAnalyticsClient }) + ).toThrowError(/CLUSTERS_JSON: invalid JSON/); }); }); @@ -656,7 +671,9 @@ describe("MCP server integration (in-process Client + Server)", () => { delete cluster[field]; withClustersJson(JSON.stringify([cluster]), () => { - expect(() => createServer()).toThrowError( + expect(() => + createServer({ analytics: noopAnalyticsClient }) + ).toThrowError( new RegExp(`invalid clusters config[\\s\\S]*0\\.${field}`) ); }); diff --git a/src/tools/alert-triage.test.ts b/src/tools/alert-triage.test.ts index 995bce3..eebf7c0 100644 --- a/src/tools/alert-triage.test.ts +++ b/src/tools/alert-triage.test.ts @@ -12,10 +12,12 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAlertTriageTools } from "./alert-triage.js"; import { createMockMcpServer, + parseBootstrapToolText, parseToolText, type MockMcpServer, } from "../test/helpers/mockMcpServer.js"; import { createMockAlertsService } from "../test/helpers/mockServices.js"; +import { noopAnalyticsClient } from "../test/helpers/mockAnalytics.js"; import type { AlertSummary, SecurityAlert } from "../shared/types.js"; import type { AlertsService } from "../elastic/service/index.js"; @@ -58,7 +60,10 @@ describe("registerAlertTriageTools", () => { alertsService = createMockAlertsService(); vi.spyOn(fs, "existsSync").mockReturnValue(false); vi.spyOn(fs, "readFileSync").mockReturnValue("triage"); - registerAlertTriageTools(server as unknown as McpServer, { alertsService }); + registerAlertTriageTools(server as unknown as McpServer, { + alertsService, + analytics: noopAnalyticsClient, + }); }); it("registers every alert-triage tool plus its UI resource", () => { @@ -83,7 +88,7 @@ describe("registerAlertTriageTools", () => { }); describe("triage-alerts", () => { - it("forwards days/severity/limit/query to AlertsService and shapes a compact response", async () => { + it("forwards days/severity/limit/query to AlertsService and emits a bootstrap payload", async () => { vi.mocked(alertsService.getAlerts).mockResolvedValueOnce( emptySummary({ total: 12, @@ -115,19 +120,12 @@ describe("registerAlertTriageTools", () => { query: "powershell", }); - const body = parseToolText<{ - total: number; - byRule: unknown[]; - byHost: unknown[]; - alerts: { id: string }[]; - params: Record; - verdicts: unknown[]; - }>(out); - - expect(body.total).toBe(12); - expect(body.byRule).toHaveLength(10); - expect(body.byHost).toHaveLength(10); - expect(body.alerts).toHaveLength(30); + const body = parseBootstrapToolText(out, "alert-triage"); + + expect(body.summary.total).toBe(12); + expect(body.summary.byRule).toHaveLength(10); + expect(body.summary.byHost).toHaveLength(10); + expect(body.summary.alerts).toHaveLength(30); expect(body.params).toEqual({ days: 3, severity: "high", @@ -152,10 +150,7 @@ describe("registerAlertTriageTools", () => { ]; const out = await server.tool("triage-alerts").callback({ verdicts }); - const body = parseToolText<{ - params: Record; - verdicts: unknown[]; - }>(out); + const body = parseBootstrapToolText(out, "alert-triage"); expect(body.params).toEqual({ days: 7, severity: undefined, @@ -196,17 +191,9 @@ describe("registerAlertTriageTools", () => { const out = await server.tool("triage-alerts").callback({}); - const body = parseToolText<{ - alerts: { - id: string; - rule: string; - host: string; - process: string; - mitre: { tactic: string; techniques: string[] }[]; - }[]; - }>(out); - - expect(body.alerts).toEqual([ + const body = parseBootstrapToolText(out, "alert-triage"); + + expect(body.summary.alerts).toEqual([ expect.objectContaining({ id: "alert-1", rule: "Suspicious PowerShell", @@ -243,8 +230,8 @@ describe("registerAlertTriageTools", () => { ); const out = await server.tool("triage-alerts").callback({}); - const body = parseToolText<{ alerts: { mitre?: unknown[] }[] }>(out); - expect(body.alerts[0].mitre).toEqual([ + const body = parseBootstrapToolText(out, "alert-triage"); + expect(body.summary.alerts[0]?.mitre).toEqual([ { tactic: "Initial Access", techniques: [] }, ]); }); diff --git a/src/tools/alert-triage.ts b/src/tools/alert-triage.ts index dfd10bc..76db7c4 100644 --- a/src/tools/alert-triage.ts +++ b/src/tools/alert-triage.ts @@ -7,29 +7,32 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { - registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; import { z } from "zod"; import fs from "fs"; import type { SecurityAlert } from "../shared/types.js"; +import { createMcpAppBootstrap } from "../shared/mcp-app-bootstrap.js"; import type { AlertsService } from "../elastic/service/index.js"; +import type { AnalyticsClient } from "../elastic/analytics/index.js"; +import { registerTrackedAppTool } from "./tracked-app-tool.js"; import { resolveViewPath } from "./view-path.js"; const RESOURCE_URI = "ui://triage-alerts/mcp-app.html"; -/** Services the alert-triage tools depend on (default cluster only, for now). */ export interface AlertTriageToolDeps { readonly alertsService: AlertsService; + readonly analytics: AnalyticsClient; } export function registerAlertTriageTools( server: McpServer, deps: AlertTriageToolDeps ) { - const { alertsService } = deps; - registerAppTool( + const { alertsService, analytics } = deps; + registerTrackedAppTool( + analytics, server, "triage-alerts", { @@ -57,49 +60,53 @@ export function registerAlertTriageTools( }, async ({ days, severity, limit, query, verdicts }) => { const summary = await alertsService.getAlerts({ days, severity, limit, query }); - const compact = { - total: summary.total, - bySeverity: summary.bySeverity, - byRule: summary.byRule.slice(0, 10), - byHost: summary.byHost.slice(0, 10), - params: { days: days || 7, severity, limit: limit || 50, query }, - verdicts: verdicts || [], - alerts: summary.alerts.slice(0, 30).map((a) => { - const s = a._source; - return { - id: a._id, - rule: s["kibana.alert.rule.name"], - severity: s["kibana.alert.severity"], - risk_score: s["kibana.alert.risk_score"], - reason: s["kibana.alert.reason"], - host: s.host?.name, - user: s.user?.name, - process: s.process?.name, - executable: s.process?.executable, - parent_process: s.process?.parent?.name, - file: s.file?.path, - source_ip: s.source?.ip, - dest_ip: s.destination?.ip, - timestamp: s["@timestamp"], - mitre: s["kibana.alert.rule.threat"]?.map((t) => ({ - tactic: t.tactic.name, - techniques: t.technique?.map((tech) => `${tech.id} ${tech.name}`) || [], - })), - }; - }), - }; return { content: [ { type: "text" as const, - text: JSON.stringify(compact), + text: JSON.stringify( + createMcpAppBootstrap("alert-triage", { + summary: { + total: summary.total, + bySeverity: summary.bySeverity, + byRule: summary.byRule.slice(0, 10), + byHost: summary.byHost.slice(0, 10), + alerts: summary.alerts.slice(0, 30).map((a) => { + const s = a._source; + return { + id: a._id, + rule: s["kibana.alert.rule.name"], + severity: s["kibana.alert.severity"], + risk_score: s["kibana.alert.risk_score"], + reason: s["kibana.alert.reason"], + host: s.host?.name, + user: s.user?.name, + process: s.process?.name, + executable: s.process?.executable, + parent_process: s.process?.parent?.name, + file: s.file?.path, + source_ip: s.source?.ip, + dest_ip: s.destination?.ip, + timestamp: s["@timestamp"], + mitre: s["kibana.alert.rule.threat"]?.map((t) => ({ + tactic: t.tactic.name, + techniques: t.technique?.map((tech) => `${tech.id} ${tech.name}`) || [], + })), + }; + }), + }, + params: { days: days || 7, severity, limit: limit || 50, query }, + verdicts: verdicts || [], + }), + ), }, ], }; } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "poll-alerts", { @@ -122,7 +129,8 @@ export function registerAlertTriageTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "get-alert-context", { @@ -143,7 +151,8 @@ export function registerAlertTriageTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "acknowledge-alert", { @@ -162,7 +171,8 @@ export function registerAlertTriageTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "unacknowledge-alert", { @@ -181,7 +191,8 @@ export function registerAlertTriageTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "acknowledge-alerts-bulk", { diff --git a/src/tools/analytics.test.ts b/src/tools/analytics.test.ts new file mode 100644 index 0000000..fbbeadd --- /dev/null +++ b/src/tools/analytics.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { describe, it, expect, vi } from "vitest"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { registerAnalyticsTools } from "./analytics.js"; +import { createMockMcpServer } from "../test/helpers/mockMcpServer.js"; +import { createMockAnalyticsClient } from "../test/helpers/mockAnalytics.js"; +import { VIEW_IDS } from "../shared/analytics-events.js"; + +describe("registerAnalyticsTools", () => { + it("registers a single app-only tool named report-analytics-event", () => { + const server = createMockMcpServer(); + const analytics = createMockAnalyticsClient(); + + registerAnalyticsTools(server as unknown as McpServer, { analytics }); + + expect([...server.tools.keys()]).toEqual(["report-analytics-event"]); + const tool = server.tool("report-analytics-event"); + expect(tool.config._meta).toMatchObject({ ui: { visibility: ["app"] } }); + }); + + it("dispatches view_rendered events to trackViewRendered", async () => { + const server = createMockMcpServer(); + const analytics = createMockAnalyticsClient(); + + registerAnalyticsTools(server as unknown as McpServer, { analytics }); + const tool = server.tool("report-analytics-event"); + + await tool.callback({ eventType: "view_rendered", viewId: "alert-triage" }); + + expect(analytics.trackViewRendered).toHaveBeenCalledExactlyOnceWith({ + view_id: "alert-triage", + }); + }); + + it("input schema rejects unknown eventType / viewId values (forward-compat)", () => { + const server = createMockMcpServer(); + const analytics = createMockAnalyticsClient(); + + registerAnalyticsTools(server as unknown as McpServer, { analytics }); + const tool = server.tool("report-analytics-event"); + + const inputSchema = tool.config.inputSchema as z.ZodTypeAny; + + expect( + inputSchema.safeParse({ eventType: "view_action", viewId: "alert-triage" }) + .success, + ).toBe(false); + expect( + inputSchema.safeParse({ eventType: "view_rendered", viewId: "nope" }).success, + ).toBe(false); + expect( + inputSchema.safeParse({ + eventType: "view_rendered", + viewId: "alert-triage", + }).success, + ).toBe(true); + expect( + inputSchema.safeParse({ viewId: "alert-triage" }).success, + ).toBe(false); + }); + + it("never throws if trackViewRendered itself throws", async () => { + const server = createMockMcpServer(); + const analytics = createMockAnalyticsClient(); + (analytics.trackViewRendered as ReturnType).mockImplementation(() => { + throw new Error("telemetry exploded"); + }); + + registerAnalyticsTools(server as unknown as McpServer, { analytics }); + const tool = server.tool("report-analytics-event"); + + await expect( + tool.callback({ eventType: "view_rendered", viewId: "alert-triage" }), + ).resolves.toEqual( + expect.objectContaining({ + content: [ + expect.objectContaining({ text: JSON.stringify({ ok: true }) }), + ], + }), + ); + }); + + it("warns via the injected logger when trackViewRendered throws", async () => { + const server = createMockMcpServer(); + const analytics = createMockAnalyticsClient(); + const warn = vi.fn(); + (analytics.trackViewRendered as ReturnType).mockImplementation(() => { + throw new Error("shipper offline"); + }); + + registerAnalyticsTools(server as unknown as McpServer, { analytics, logger: { warn } }); + const tool = server.tool("report-analytics-event"); + + await tool.callback({ eventType: "view_rendered", viewId: "alert-triage" }); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("shipper offline"), + ); + }); + + it("covers every value in VIEW_IDS without a runtime mismatch", async () => { + const server = createMockMcpServer(); + const analytics = createMockAnalyticsClient(); + + registerAnalyticsTools(server as unknown as McpServer, { analytics }); + const tool = server.tool("report-analytics-event"); + + for (const viewId of VIEW_IDS) { + await tool.callback({ eventType: "view_rendered", viewId }); + } + + expect(analytics.trackViewRendered).toHaveBeenCalledTimes(VIEW_IDS.length); + }); +}); diff --git a/src/tools/analytics.ts b/src/tools/analytics.ts new file mode 100644 index 0000000..b097b68 --- /dev/null +++ b/src/tools/analytics.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; +import { z } from "zod"; +import { + VIEW_IDS, + type AnalyticsClient, +} from "../elastic/analytics/index.js"; +import { createStderrLogger, type Logger } from "../shared/logger.js"; + +export interface AnalyticsToolDeps { + readonly analytics: Pick; + readonly logger?: Pick; +} + +/** + * Wire schema for the app-only `report-analytics-event` MCP tool. + * + * Mirrors the {@link AnalyticsEvent} TypeScript discriminated union on + * the React side — both ends must stay aligned. Adding an event type + * means adding a `z.object` here, a variant to `AnalyticsEvent`, and a + * case to the handler's `switch (eventType)` below. + * + * Kept as a closed discriminated union (not `z.object({...}).passthrough()` + * or similar) so a malicious or buggy view can't smuggle free-form text + * into the telemetry pipeline. + */ +const analyticsEventSchema = z.discriminatedUnion("eventType", [ + z.object({ + eventType: z.literal("view_rendered"), + viewId: z.enum(VIEW_IDS), + }), +]); + +/** + * Register the app-only `report-analytics-event` MCP tool used by the + * frontend `useAnalytics()` hook. + * + * The handler is intentionally not wrapped with `registerTrackedAppTool`: + * tracking the report-event call itself would produce noisy + * `mcp_tool_called` events that just mirror the report-event traffic. + */ +export function registerAnalyticsTools( + server: McpServer, + deps: AnalyticsToolDeps, +): void { + const { analytics } = deps; + const logger = deps.logger ?? createStderrLogger(["analytics-tool"]); + + registerAppTool( + server, + "report-analytics-event", + { + title: "Report Analytics Event", + description: "Internal: report a UI analytics event", + inputSchema: analyticsEventSchema, + _meta: { ui: { visibility: ["app"] } }, + }, + async (event) => { + try { + switch (event.eventType) { + case "view_rendered": + analytics.trackViewRendered({ view_id: event.viewId }); + break; + default: { + const _exhaustive: never = event.eventType; + void _exhaustive; + } + } + } catch (err) { + logger.warn( + `report-analytics-event: trackViewRendered failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + return { + content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }], + }; + }, + ); +} diff --git a/src/tools/attack-discovery.test.ts b/src/tools/attack-discovery.test.ts index 2a3d1a8..006a3d4 100644 --- a/src/tools/attack-discovery.test.ts +++ b/src/tools/attack-discovery.test.ts @@ -12,6 +12,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerAttackDiscoveryTools } from "./attack-discovery.js"; import { createMockMcpServer, + parseBootstrapToolText, parseToolText, type MockMcpServer, } from "../test/helpers/mockMcpServer.js"; @@ -19,6 +20,8 @@ import { createMockAttackDiscoveryService, createMockCasesService, } from "../test/helpers/mockServices.js"; +import { createMockAnalyticsClient } from "../test/helpers/mockAnalytics.js"; +import type { AnalyticsClient } from "../elastic/analytics/index.js"; import type { AttackDiscovery, TriagedDiscovery, @@ -31,11 +34,6 @@ import type { KibanaCase } from "../shared/types.js"; const RESOURCE_URI = "ui://triage-attack-discoveries/mcp-app.html"; -/** - * Build a `KibanaCase` stub for tests that only care about a couple of - * fields. Using `Partial` + a single `as unknown as` cast keeps - * the test bodies focused on the fields under assertion. - */ function caseStub(overrides: Partial): KibanaCase { return overrides as unknown as KibanaCase; } @@ -60,16 +58,19 @@ describe("registerAttackDiscoveryTools", () => { let server: MockMcpServer; let attackDiscoveryService: AttackDiscoveryService; let casesService: CasesService; + let analytics: AnalyticsClient; beforeEach(() => { server = createMockMcpServer(); attackDiscoveryService = createMockAttackDiscoveryService(); casesService = createMockCasesService(); + analytics = createMockAnalyticsClient(); vi.spyOn(fs, "existsSync").mockReturnValue(false); vi.spyOn(fs, "readFileSync").mockReturnValue("ad"); registerAttackDiscoveryTools(server as unknown as McpServer, { attackDiscoveryService, casesService, + analytics, }); }); @@ -91,6 +92,21 @@ describe("registerAttackDiscoveryTools", () => { }); describe("triage-attack-discoveries", () => { + it("emits successful telemetry for the registered tool callback", async () => { + vi.mocked(attackDiscoveryService.getDiscoveries).mockResolvedValueOnce({ + total: 0, + discoveries: [], + }); + + await server.tool("triage-attack-discoveries").callback({ days: 2, limit: 10 }); + + expect(analytics.trackToolCalled).toHaveBeenCalledExactlyOnceWith({ + tool_id: "triage-attack-discoveries", + duration_ms: expect.any(Number), + success: true, + }); + }); + it("calls assessConfidence and returns triaged discoveries with confidence fields", async () => { const discoveries = [makeDiscovery()]; vi.mocked(attackDiscoveryService.getDiscoveries).mockResolvedValueOnce({ @@ -127,11 +143,7 @@ describe("registerAttackDiscoveryTools", () => { discoveries ); - const body = parseToolText<{ - total: number; - params: Record; - discoveries: { id: string; confidence?: string; hosts?: string[] }[]; - }>(out); + const body = parseBootstrapToolText(out, "attack-discovery"); expect(body.total).toBe(1); expect(body.params).toEqual({ days: 2, limit: 10 }); expect(body.discoveries[0]).toMatchObject({ @@ -157,10 +169,7 @@ describe("registerAttackDiscoveryTools", () => { .tool("triage-attack-discoveries") .callback({}); - const body = parseToolText<{ - params: { days: number; limit: number }; - discoveries: { id: string; confidence?: string }[]; - }>(out); + const body = parseBootstrapToolText(out, "attack-discovery"); expect(body.params).toEqual({ days: 1, limit: 50 }); expect(body.discoveries[0].confidence).toBeUndefined(); }); @@ -174,7 +183,7 @@ describe("registerAttackDiscoveryTools", () => { const out = await server.tool("triage-attack-discoveries").callback({}); expect(attackDiscoveryService.assessConfidence).not.toHaveBeenCalled(); - const body = parseToolText<{ discoveries: unknown[] }>(out); + const body = parseBootstrapToolText(out, "attack-discovery"); expect(body.discoveries).toEqual([]); }); @@ -202,12 +211,25 @@ describe("registerAttackDiscoveryTools", () => { ); const out = await server.tool("triage-attack-discoveries").callback({}); - const body = parseToolText<{ discoveries: { id: string }[] }>(out); + const body = parseBootstrapToolText(out, "attack-discovery"); expect(body.discoveries).toHaveLength(20); }); }); describe("poll-discoveries", () => { + it("emits failed telemetry when the tool callback rejects", async () => { + const boom = new Error("service unavailable"); + vi.mocked(attackDiscoveryService.getDiscoveries).mockRejectedValueOnce(boom); + + await expect(server.tool("poll-discoveries").callback({})).rejects.toBe(boom); + + expect(analytics.trackToolCalled).toHaveBeenCalledExactlyOnceWith({ + tool_id: "poll-discoveries", + duration_ms: expect.any(Number), + success: false, + }); + }); + it("returns the raw discovery summary as JSON", async () => { const summary = { total: 1, discoveries: [makeDiscovery()] }; vi.mocked(attackDiscoveryService.getDiscoveries).mockResolvedValueOnce( diff --git a/src/tools/attack-discovery.ts b/src/tools/attack-discovery.ts index 4c71da8..286d529 100644 --- a/src/tools/attack-discovery.ts +++ b/src/tools/attack-discovery.ts @@ -7,17 +7,19 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { - registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; import { z } from "zod"; import fs from "fs"; +import { createMcpAppBootstrap } from "../shared/mcp-app-bootstrap.js"; import type { AttackDiscovery } from "../elastic/client/index.js"; import type { AttackDiscoveryService, CasesService, } from "../elastic/service/index.js"; +import type { AnalyticsClient } from "../elastic/analytics/index.js"; +import { registerTrackedAppTool } from "./tracked-app-tool.js"; import { resolveViewPath } from "./view-path.js"; const RESOURCE_URI = "ui://triage-attack-discoveries/mcp-app.html"; @@ -78,18 +80,19 @@ function splitDiscoveryDetails(detailsMarkdown: string | undefined): { }; } -/** Services the attack-discovery tools depend on (default cluster only, for now). */ export interface AttackDiscoveryToolDeps { readonly attackDiscoveryService: AttackDiscoveryService; readonly casesService: CasesService; + readonly analytics: AnalyticsClient; } export function registerAttackDiscoveryTools( server: McpServer, deps: AttackDiscoveryToolDeps ) { - const { attackDiscoveryService, casesService } = deps; - registerAppTool( + const { attackDiscoveryService, casesService, analytics } = deps; + registerTrackedAppTool( + analytics, server, "triage-attack-discoveries", { @@ -114,41 +117,26 @@ export function registerAttackDiscoveryTools( } } - const compact = { - total: summary.total, - params: { days: days || 1, limit: limit || 50 }, - discoveries: (triaged || summary.discoveries).slice(0, 20).map((d) => { - const base: Record = { - id: d.id, - title: d.title, - summaryMarkdown: d.summaryMarkdown, - detailsMarkdown: d.detailsMarkdown, - mitreTactics: d.mitreTactics, - alertIds: d.alertIds, - alertCount: d.alertIds.length, - alertsContextCount: d.alertsContextCount, - riskScore: d.riskScore, - timestamp: d.timestamp, - }; - const td = d as unknown as Record; - if (td.confidence !== undefined) { - base.confidence = td.confidence; - base.hosts = td.hosts; - base.users = td.users; - base.ruleNames = td.ruleNames; - base.signals = td.signals; - } - return base; - }), - }; - return { - content: [{ type: "text" as const, text: JSON.stringify(compact) }], + content: [{ + type: "text" as const, + text: JSON.stringify( + createMcpAppBootstrap("attack-discovery", { + total: summary.total, + params: { days: days || 1, limit: limit || 50 }, + discoveries: (triaged || summary.discoveries).slice(0, 20).map((d) => ({ + ...d, + alertCount: d.alertIds.length, + })), + }), + ), + }], }; } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "poll-discoveries", { @@ -168,7 +156,8 @@ export function registerAttackDiscoveryTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "assess-discovery-confidence", { @@ -188,7 +177,8 @@ export function registerAttackDiscoveryTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "enrich-discovery", { @@ -208,7 +198,8 @@ export function registerAttackDiscoveryTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "approve-discoveries", { @@ -236,7 +227,6 @@ export function registerAttackDiscoveryTools( for (const finding of findings) { const { immediateActions, attackChain } = splitDiscoveryDetails(finding.detailsMarkdown); - // Description: short, predictable structure — summary + risk metadata + Immediate actions. const descriptionLines: string[] = [ `## Attack Discovery Finding`, ``, @@ -259,8 +249,6 @@ export function registerAttackDiscoveryTools( severity: finding.riskScore >= 80 ? "critical" : finding.riskScore >= 60 ? "high" : finding.riskScore >= 40 ? "medium" : "low", }); - // First comment: the full attack chain narrative (everything except - // the Immediate Actions section, which is already in the description). if (attackChain) { try { await casesService.addComment( @@ -268,7 +256,6 @@ export function registerAttackDiscoveryTools( [`## Attack chain`, ``, attackChain].join("\n") ); } catch { - // comment failed — case still created } } @@ -286,7 +273,8 @@ export function registerAttackDiscoveryTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "acknowledge-discoveries", { @@ -305,9 +293,8 @@ export function registerAttackDiscoveryTools( } ); - // ─── On-Demand Generation ─── - - registerAppTool( + registerTrackedAppTool( + analytics, server, "generate-attack-discovery", { @@ -350,7 +337,8 @@ export function registerAttackDiscoveryTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "get-generation-status", { @@ -369,7 +357,8 @@ export function registerAttackDiscoveryTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "list-ai-connectors", { diff --git a/src/tools/case-management.test.ts b/src/tools/case-management.test.ts index 9ea4ad9..d995135 100644 --- a/src/tools/case-management.test.ts +++ b/src/tools/case-management.test.ts @@ -12,10 +12,12 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerCaseManagementTools } from "./case-management.js"; import { createMockMcpServer, + parseBootstrapToolText, parseToolText, type MockMcpServer, } from "../test/helpers/mockMcpServer.js"; import { createMockCasesService } from "../test/helpers/mockServices.js"; +import { noopAnalyticsClient } from "../test/helpers/mockAnalytics.js"; import type { CasesService } from "../elastic/service/index.js"; import type { KibanaCase } from "../shared/types.js"; @@ -52,6 +54,7 @@ describe("registerCaseManagementTools", () => { vi.spyOn(fs, "readFileSync").mockReturnValue("cases"); registerCaseManagementTools(server as unknown as McpServer, { casesService, + analytics: noopAnalyticsClient, }); }); @@ -74,7 +77,7 @@ describe("registerCaseManagementTools", () => { }); describe("manage-cases", () => { - it("forwards filter params and emits a compact response (truncated description, sliced tags, max 20 cases)", async () => { + it("forwards filter params and emits a bootstrap payload for the initial list", async () => { const cases = Array.from({ length: 25 }, (_, i) => makeCase({ id: `case-${i}` }) ); @@ -97,15 +100,11 @@ describe("registerCaseManagementTools", () => { search: "ransomware", }); - const body = parseToolText<{ - total: number; - cases: { id: string; description: string; tags: string[] }[]; - params: Record; - }>(out); + const body = parseBootstrapToolText(out, "case-management"); expect(body.total).toBe(25); expect(body.cases).toHaveLength(20); - expect(body.cases[0].description.length).toBe(300); - expect(body.cases[0].tags).toHaveLength(10); + expect(body.cases[0]?.description?.length).toBe(300); + expect(body.cases[0]?.tags).toHaveLength(10); expect(body.params).toEqual({ status: "open", severity: "high", @@ -128,12 +127,10 @@ describe("registerCaseManagementTools", () => { }); const out = await server.tool("manage-cases").callback({}); - const body = parseToolText<{ - cases: { description?: string; tags?: string[]; created_by?: string }[]; - }>(out); - expect(body.cases[0].description).toBeUndefined(); - expect(body.cases[0].tags).toBeUndefined(); - expect(body.cases[0].created_by).toBe("alice"); + const body = parseBootstrapToolText(out, "case-management"); + expect(body.cases[0]?.description).toBeUndefined(); + expect(body.cases[0]?.tags).toBeUndefined(); + expect(body.cases[0]?.created_by).toBe("alice"); }); }); diff --git a/src/tools/case-management.ts b/src/tools/case-management.ts index e71a736..692a7e0 100644 --- a/src/tools/case-management.ts +++ b/src/tools/case-management.ts @@ -7,28 +7,31 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { - registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; import { z } from "zod"; import fs from "fs"; +import { createMcpAppBootstrap } from "../shared/mcp-app-bootstrap.js"; import type { CasesService } from "../elastic/service/index.js"; +import type { AnalyticsClient } from "../elastic/analytics/index.js"; +import { registerTrackedAppTool } from "./tracked-app-tool.js"; import { resolveViewPath } from "./view-path.js"; const RESOURCE_URI = "ui://manage-cases/mcp-app.html"; -/** Services the case-management tools depend on (default cluster only, for now). */ export interface CaseManagementToolDeps { readonly casesService: CasesService; + readonly analytics: AnalyticsClient; } export function registerCaseManagementTools( server: McpServer, deps: CaseManagementToolDeps ) { - const { casesService } = deps; - registerAppTool( + const { casesService, analytics } = deps; + registerTrackedAppTool( + analytics, server, "manage-cases", { @@ -44,25 +47,35 @@ export function registerCaseManagementTools( }, async ({ status, severity, search }) => { const result = await casesService.listCases({ status, severity, search }); - const compact = { - total: result.total, - cases: result.cases.slice(0, 20).map((c) => ({ - id: c.id, title: c.title, status: c.status, severity: c.severity, - totalAlerts: c.totalAlerts, totalComment: c.totalComment, - tags: c.tags?.slice(0, 10), - description: c.description?.substring(0, 300), - created_at: c.created_at, updated_at: c.updated_at, - created_by: c.created_by?.username, - })), - params: { status, severity, search }, - }; return { - content: [{ type: "text" as const, text: JSON.stringify(compact) }], + content: [{ + type: "text" as const, + text: JSON.stringify( + createMcpAppBootstrap("case-management", { + total: result.total, + cases: result.cases.slice(0, 20).map((c) => ({ + id: c.id, + title: c.title, + status: c.status, + severity: c.severity, + totalAlerts: c.totalAlerts, + totalComment: c.totalComment, + tags: c.tags?.slice(0, 10), + description: c.description?.substring(0, 300), + created_at: c.created_at, + updated_at: c.updated_at, + created_by: c.created_by?.username, + })), + params: { status, severity, search }, + }), + ), + }], }; } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "list-cases", { @@ -91,7 +104,8 @@ export function registerCaseManagementTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "get-case", { @@ -106,7 +120,8 @@ export function registerCaseManagementTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "create-case", { @@ -138,7 +153,8 @@ export function registerCaseManagementTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "update-case", { @@ -163,7 +179,8 @@ export function registerCaseManagementTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "add-case-comment", { @@ -181,7 +198,8 @@ export function registerCaseManagementTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "attach-alert-to-case", { @@ -202,7 +220,8 @@ export function registerCaseManagementTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "get-case-alerts", { @@ -221,7 +240,8 @@ export function registerCaseManagementTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "get-case-comments", { @@ -236,7 +256,8 @@ export function registerCaseManagementTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "get-user-profile", { diff --git a/src/tools/detection-rules.test.ts b/src/tools/detection-rules.test.ts index ccc8041..ad032fb 100644 --- a/src/tools/detection-rules.test.ts +++ b/src/tools/detection-rules.test.ts @@ -12,10 +12,12 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerDetectionRuleTools } from "./detection-rules.js"; import { createMockMcpServer, + parseBootstrapToolText, parseToolText, type MockMcpServer, } from "../test/helpers/mockMcpServer.js"; import { createMockRulesService } from "../test/helpers/mockServices.js"; +import { noopAnalyticsClient } from "../test/helpers/mockAnalytics.js"; import type { RulesService } from "../elastic/service/index.js"; import type { DetectionRule } from "../shared/types.js"; @@ -59,7 +61,10 @@ describe("registerDetectionRuleTools", () => { rulesService = createMockRulesService(); vi.spyOn(fs, "existsSync").mockReturnValue(false); vi.spyOn(fs, "readFileSync").mockReturnValue("rules"); - registerDetectionRuleTools(server as unknown as McpServer, { rulesService }); + registerDetectionRuleTools(server as unknown as McpServer, { + rulesService, + analytics: noopAnalyticsClient, + }); }); it("registers every detection-rules tool plus the UI resource", () => { @@ -80,7 +85,7 @@ describe("registerDetectionRuleTools", () => { }); describe("manage-rules", () => { - it("forwards filter/page/perPage and shapes a compact response", async () => { + it("forwards filter/page/perPage and emits a bootstrap payload", async () => { const rules = Array.from({ length: 25 }, (_, i) => makeRule({ id: `r-${i}` }) ); @@ -103,23 +108,13 @@ describe("registerDetectionRuleTools", () => { perPage: 20, }); - const body = parseToolText<{ - total: number; - rules: { - id: string; - description: string; - query: string; - tags: string[]; - threat: { tactic: string; techniques: string[] }[]; - }[]; - params: Record; - }>(out); + const body = parseBootstrapToolText(out, "detection-rules"); expect(body.total).toBe(25); expect(body.rules).toHaveLength(20); - expect(body.rules[0].description.length).toBe(200); - expect(body.rules[0].query.length).toBe(300); - expect(body.rules[0].tags).toHaveLength(10); - expect(body.rules[0].threat).toEqual([ + expect(body.rules[0]?.description?.length).toBe(200); + expect(body.rules[0]?.query?.length).toBe(300); + expect(body.rules[0]?.tags).toHaveLength(10); + expect(body.rules[0]?.threat).toEqual([ { tactic: "Initial Access", techniques: ["T1059 Command and Scripting"], @@ -150,11 +145,12 @@ describe("registerDetectionRuleTools", () => { }); const out = await server.tool("manage-rules").callback({}); - const body = parseToolText<{ - rules: { threat: { tactic: string; techniques: string[] }[] }[]; - }>(out); - expect(body.rules[0].threat).toEqual([ - { tactic: "Initial Access", techniques: [] }, + const body = parseBootstrapToolText(out, "detection-rules"); + expect(body.rules[0]?.threat).toEqual([ + { + tactic: "Initial Access", + techniques: [], + }, ]); }); @@ -174,18 +170,11 @@ describe("registerDetectionRuleTools", () => { }); const out = await server.tool("manage-rules").callback({}); - const body = parseToolText<{ - rules: { - description?: string; - query?: string; - tags?: string[]; - threat?: unknown; - }[]; - }>(out); - expect(body.rules[0].description).toBeUndefined(); - expect(body.rules[0].query).toBeUndefined(); - expect(body.rules[0].tags).toBeUndefined(); - expect(body.rules[0].threat).toBeUndefined(); + const body = parseBootstrapToolText(out, "detection-rules"); + expect(body.rules[0]?.description).toBeUndefined(); + expect(body.rules[0]?.query).toBeUndefined(); + expect(body.rules[0]?.tags).toBeUndefined(); + expect(body.rules[0]?.threat).toBeUndefined(); }); }); diff --git a/src/tools/detection-rules.ts b/src/tools/detection-rules.ts index 8e61b1f..117e46d 100644 --- a/src/tools/detection-rules.ts +++ b/src/tools/detection-rules.ts @@ -7,28 +7,31 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { - registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; import { z } from "zod"; import fs from "fs"; +import { createMcpAppBootstrap } from "../shared/mcp-app-bootstrap.js"; import type { RulesService } from "../elastic/service/index.js"; +import type { AnalyticsClient } from "../elastic/analytics/index.js"; +import { registerTrackedAppTool } from "./tracked-app-tool.js"; import { resolveViewPath } from "./view-path.js"; const RESOURCE_URI = "ui://manage-rules/mcp-app.html"; -/** Services the detection-rules tools depend on (default cluster only, for now). */ export interface DetectionRuleToolDeps { readonly rulesService: RulesService; + readonly analytics: AnalyticsClient; } export function registerDetectionRuleTools( server: McpServer, deps: DetectionRuleToolDeps ) { - const { rulesService } = deps; - registerAppTool( + const { rulesService, analytics } = deps; + registerTrackedAppTool( + analytics, server, "manage-rules", { @@ -44,29 +47,38 @@ export function registerDetectionRuleTools( }, async ({ filter, page, perPage }) => { const result = await rulesService.findRules({ filter, page, perPage }); - const compact = { - total: result.total, - rules: result.data.slice(0, 20).map((r) => ({ - id: r.id, name: r.name, type: r.type, severity: r.severity, - enabled: r.enabled, risk_score: r.risk_score, - description: r.description?.substring(0, 200), - query: r.query?.substring(0, 300), - language: r.language, - tags: r.tags?.slice(0, 10), - threat: r.threat?.map((t: any) => ({ - tactic: t.tactic?.name, - techniques: t.technique?.map((tech: any) => tech.id + ' ' + tech.name) || [], - })), - })), - params: { filter, page, perPage }, - }; return { - content: [{ type: "text" as const, text: JSON.stringify(compact) }], + content: [{ + type: "text" as const, + text: JSON.stringify( + createMcpAppBootstrap("detection-rules", { + total: result.total, + rules: result.data.slice(0, 20).map((r) => ({ + id: r.id, + name: r.name, + type: r.type, + severity: r.severity, + enabled: r.enabled, + risk_score: r.risk_score, + description: r.description?.substring(0, 200), + query: r.query?.substring(0, 300), + language: r.language, + tags: r.tags?.slice(0, 10), + threat: r.threat?.map((t) => ({ + tactic: t.tactic?.name, + techniques: t.technique?.map((tech) => `${tech.id} ${tech.name}`) || [], + })), + })), + params: { filter, page, perPage }, + }), + ), + }], }; } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "find-rules", { @@ -87,7 +99,8 @@ export function registerDetectionRuleTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "get-rule", { @@ -102,7 +115,8 @@ export function registerDetectionRuleTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "create-rule", { @@ -120,7 +134,8 @@ export function registerDetectionRuleTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "patch-rule", { @@ -141,7 +156,8 @@ export function registerDetectionRuleTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "toggle-rule", { @@ -159,7 +175,8 @@ export function registerDetectionRuleTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "validate-query", { @@ -177,7 +194,8 @@ export function registerDetectionRuleTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "noisy-rules", { @@ -195,7 +213,8 @@ export function registerDetectionRuleTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "manage-exceptions", { diff --git a/src/tools/sample-data.test.ts b/src/tools/sample-data.test.ts index da9f907..992fdfa 100644 --- a/src/tools/sample-data.test.ts +++ b/src/tools/sample-data.test.ts @@ -9,12 +9,25 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import fs from "fs"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +// `sample-data.ts` → `service/index.ts` → `telemetryService.ts` → +// `analytics/index.ts` → `create-analytics-client.ts` → `@elastic/ebt`. +// The `vi.resetModules()` pattern in this file causes a fresh CJS load of +// `@elastic/ebt` on every dynamic re-import, which fails in vitest's ESM +// context. Mocking the boundary that imports ebt prevents the load entirely; +// the mock persists across `vi.resetModules()` calls. +vi.mock("../elastic/analytics/create-analytics-client.js", () => ({ + createAnalyticsClient: vi.fn(), + resolveTelemetrySendTo: vi.fn().mockReturnValue("production"), +})); + import { createMockMcpServer, + parseBootstrapToolText, parseToolText, type MockMcpServer, } from "../test/helpers/mockMcpServer.js"; import { createMockSampleDataService } from "../test/helpers/mockServices.js"; +import { noopAnalyticsClient } from "../test/helpers/mockAnalytics.js"; import type { SampleDataService } from "../elastic/service/index.js"; const RESOURCE_URI = "ui://generate-sample-data/mcp-app.html"; @@ -32,7 +45,10 @@ async function setup(): Promise<{ const { registerSampleDataTools } = await import("./sample-data.js"); const server = createMockMcpServer(); const sampleDataService = createMockSampleDataService(); - registerSampleDataTools(server as unknown as McpServer, { sampleDataService }); + registerSampleDataTools(server as unknown as McpServer, { + sampleDataService, + analytics: noopAnalyticsClient, + }); return { server, sampleDataService }; } @@ -57,13 +73,19 @@ describe("registerSampleDataTools", () => { }); describe("generate-sample-data", () => { - it("returns the static `ready` envelope listing every supported scenario", async () => { - const { server } = await setup(); + it("returns a bootstrap payload listing scenarios and current sample data", async () => { + const { server, sampleDataService } = await setup(); + vi.mocked(sampleDataService.checkExistingData).mockResolvedValueOnce({ + totalDocs: 42, + totalAlerts: 5, + existingRules: 3, + byScenario: {}, + }); const out = await server.tool("generate-sample-data").callback({}); - const body = parseToolText<{ status: string; scenarios: string[] }>(out); - expect(body.status).toBe("ready"); + const body = parseBootstrapToolText(out, "sample-data"); expect(body.scenarios).toContain("ransomware-kill-chain"); expect(body.scenarios.length).toBeGreaterThan(5); + expect(body.existingData.totalDocs).toBe(42); }); it("documents the supported scenarios in the tool description", async () => { diff --git a/src/tools/sample-data.ts b/src/tools/sample-data.ts index c646b41..c92b26a 100644 --- a/src/tools/sample-data.ts +++ b/src/tools/sample-data.ts @@ -7,7 +7,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { - registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; @@ -19,23 +18,27 @@ import { type SampleDataService, type ScenarioName, } from "../elastic/service/index.js"; +import { createMcpAppBootstrap } from "../shared/mcp-app-bootstrap.js"; +import type { AnalyticsClient } from "../elastic/analytics/index.js"; +import { registerTrackedAppTool } from "./tracked-app-tool.js"; import { resolveViewPath } from "./view-path.js"; const RESOURCE_URI = "ui://generate-sample-data/mcp-app.html"; const _pendingRuleIdMap: Record = {}; -/** Services the sample-data tools depend on (default cluster only, for now). */ export interface SampleDataToolDeps { readonly sampleDataService: SampleDataService; + readonly analytics: AnalyticsClient; } export function registerSampleDataTools( server: McpServer, deps: SampleDataToolDeps ) { - const { sampleDataService } = deps; - registerAppTool( + const { sampleDataService, analytics } = deps; + registerTrackedAppTool( + analytics, server, "generate-sample-data", { @@ -45,13 +48,23 @@ export function registerSampleDataTools( _meta: { ui: { resourceUri: RESOURCE_URI } }, }, async () => { + const existingData = await sampleDataService.checkExistingData(); return { - content: [{ type: "text" as const, text: JSON.stringify({ status: "ready", scenarios: SCENARIO_NAMES }) }], + content: [{ + type: "text" as const, + text: JSON.stringify( + createMcpAppBootstrap("sample-data", { + scenarios: SCENARIO_NAMES, + existingData, + }), + ), + }], }; } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "generate-scenario", { @@ -76,7 +89,8 @@ export function registerSampleDataTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "cleanup-sample-data", { @@ -91,7 +105,8 @@ export function registerSampleDataTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "create-rules-for-scenario", { @@ -113,7 +128,8 @@ export function registerSampleDataTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "check-existing-sample-data", { diff --git a/src/tools/threat-hunt.test.ts b/src/tools/threat-hunt.test.ts index f470027..586221a 100644 --- a/src/tools/threat-hunt.test.ts +++ b/src/tools/threat-hunt.test.ts @@ -12,6 +12,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerThreatHuntTools } from "./threat-hunt.js"; import { createMockMcpServer, + parseBootstrapToolText, parseToolText, type MockMcpServer, } from "../test/helpers/mockMcpServer.js"; @@ -21,6 +22,7 @@ import { createMockIndicesService, createMockInvestigateService, } from "../test/helpers/mockServices.js"; +import { noopAnalyticsClient } from "../test/helpers/mockAnalytics.js"; import type { EntityDetailService, EsqlService, @@ -50,6 +52,7 @@ describe("registerThreatHuntTools", () => { indicesService, investigateService, entityDetailService, + analytics: noopAnalyticsClient, }); }); @@ -68,7 +71,7 @@ describe("registerThreatHuntTools", () => { }); describe("threat-hunt", () => { - it("returns a compact summary listing the first 20 indices", async () => { + it("returns a bootstrap payload listing the first 20 indices", async () => { vi.mocked(indicesService.listIndices).mockResolvedValueOnce( Array.from({ length: 25 }, (_, i) => ({ index: `idx-${i}`, @@ -81,16 +84,13 @@ describe("registerThreatHuntTools", () => { const out = await server.tool("threat-hunt").callback({}); - const body = parseToolText<{ - indexCount: number; - indices: string[]; - }>(out); + const body = parseBootstrapToolText(out, "threat-hunt"); expect(body.indexCount).toBe(25); expect(body.indices).toHaveLength(20); expect(body.indices[0]).toBe("idx-0"); }); - it("executes the supplied query, formats the rows, and truncates long string cells at 100 chars", async () => { + it("executes the supplied query and includes the ES|QL result in the bootstrap payload", async () => { vi.mocked(indicesService.listIndices).mockResolvedValueOnce([]); vi.mocked(esqlService.executeEsql).mockResolvedValueOnce({ columns: [{ name: "host", type: "keyword" }], @@ -104,21 +104,22 @@ describe("registerThreatHuntTools", () => { description: "look for foo", }); - const body = parseToolText<{ - query: string; - rowCount: number; - columns: string[]; - rows: (string | null)[][]; - description: string; - }>(out); - expect(body.query).toBe("FROM logs-* | LIMIT 30"); - expect(body.description).toBe("look for foo"); - expect(body.rowCount).toBe(30); - expect(body.columns).toEqual(["host"]); - expect(body.rows).toHaveLength(20); - expect(body.rows[0][0]).toMatch(/x{100}\.\.\.$/); - expect(body.rows[1][0]).toBeNull(); - expect(body.rows[2][0]).toBe('{"a":1}'); + const body = parseBootstrapToolText(out, "threat-hunt"); + expect(body.params.query).toBe("FROM logs-* | LIMIT 30"); + expect(body.params.description).toBe("look for foo"); + expect(body.queryResult).toEqual({ + rowCount: 30, + columns: ["host"], + rows: Array.from({ length: 20 }, (_, i) => [ + i === 0 + ? `${"x".repeat(100)}...` + : i === 1 + ? null + : i === 2 + ? "{\"a\":1}" + : `host-${i}`, + ]), + }); }); it("captures query errors in `queryError` rather than throwing", async () => { @@ -131,8 +132,8 @@ describe("registerThreatHuntTools", () => { .tool("threat-hunt") .callback({ query: "FROM bogus" }); - const body = parseToolText<{ query: string; queryError: string }>(out); - expect(body.query).toBe("FROM bogus"); + const body = parseBootstrapToolText(out, "threat-hunt"); + expect(body.params.query).toBe("FROM bogus"); expect(body.queryError).toBe("syntax error at line 1"); }); @@ -144,7 +145,7 @@ describe("registerThreatHuntTools", () => { .tool("threat-hunt") .callback({ query: "FROM bogus" }); - const body = parseToolText<{ queryError: string }>(out); + const body = parseBootstrapToolText(out, "threat-hunt"); expect(body.queryError).toBe("network blip"); }); @@ -166,12 +167,9 @@ describe("registerThreatHuntTools", () => { "host", "host-1" ); - const body = parseToolText<{ - entity: { type: string; value: string }; - graph: { nodeCount: number; edgeCount: number }; - }>(out); - expect(body.entity).toEqual({ type: "host", value: "host-1" }); - expect(body.graph).toEqual({ nodeCount: 2, edgeCount: 1 }); + const body = parseBootstrapToolText(out, "threat-hunt"); + expect(body.params.entity).toEqual({ type: "host", value: "host-1" }); + expect(body.entityGraph).toEqual({ nodeCount: 2, edgeCount: 1 }); }); it("swallows entity-investigation failures rather than failing the whole hunt", async () => { diff --git a/src/tools/threat-hunt.ts b/src/tools/threat-hunt.ts index 2c59d18..1881586 100644 --- a/src/tools/threat-hunt.ts +++ b/src/tools/threat-hunt.ts @@ -7,7 +7,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { - registerAppTool, registerAppResource, RESOURCE_MIME_TYPE, } from "@modelcontextprotocol/ext-apps/server"; @@ -19,24 +18,28 @@ import type { IndicesService, InvestigateService, } from "../elastic/service/index.js"; +import { createMcpAppBootstrap } from "../shared/mcp-app-bootstrap.js"; +import type { AnalyticsClient } from "../elastic/analytics/index.js"; +import { registerTrackedAppTool } from "./tracked-app-tool.js"; import { resolveViewPath } from "./view-path.js"; const RESOURCE_URI = "ui://threat-hunt/mcp-app.html"; -/** Services the threat-hunt tools depend on (default cluster only, for now). */ export interface ThreatHuntToolDeps { readonly esqlService: EsqlService; readonly indicesService: IndicesService; readonly investigateService: InvestigateService; readonly entityDetailService: EntityDetailService; + readonly analytics: AnalyticsClient; } export function registerThreatHuntTools( server: McpServer, deps: ThreatHuntToolDeps ) { - const { esqlService, indicesService, investigateService, entityDetailService } = deps; - registerAppTool( + const { esqlService, indicesService, investigateService, entityDetailService, analytics } = deps; + registerTrackedAppTool( + analytics, server, "threat-hunt", { @@ -55,44 +58,63 @@ export function registerThreatHuntTools( }, async ({ query, description, entity }) => { const indices = await indicesService.listIndices(); - const compact: Record = { + const payload: { + indexCount: number; + indices: string[]; + params: { query?: string; description?: string; entity?: typeof entity }; + queryResult?: { + columns: string[]; + rows: (string | number | boolean | null)[][]; + rowCount: number; + }; + queryError?: string; + entityGraph?: { nodeCount: number; edgeCount: number }; + } = { indexCount: indices.length, indices: indices.slice(0, 20).map((i) => i.index), + params: { query, description, entity }, }; if (query) { try { const qr = await esqlService.executeEsql(query); - compact.query = query; - compact.rowCount = qr.values.length; - compact.columns = qr.columns.map((c) => c.name); - compact.rows = qr.values.slice(0, 20).map((row) => - row.map((cell) => { - if (cell === null || cell === undefined) return null; - const s = typeof cell === "object" ? JSON.stringify(cell) : String(cell); - return s.length > 100 ? s.substring(0, 100) + "..." : s; - }) - ); + payload.queryResult = { + rowCount: qr.values.length, + columns: qr.columns.map((c) => c.name), + rows: qr.values.slice(0, 20).map((row) => + row.map((cell) => { + if (cell === null || cell === undefined) return null; + const value = + typeof cell === "string" || + typeof cell === "number" || + typeof cell === "boolean" + ? cell + : JSON.stringify(cell); + const s = typeof value === "string" ? value : String(value); + return s.length > 100 ? `${s.substring(0, 100)}...` : value; + }), + ), + }; } catch (e) { - compact.query = query; - compact.queryError = e instanceof Error ? e.message : String(e); + payload.queryError = e instanceof Error ? e.message : String(e); } } - if (description) compact.description = description; if (entity) { try { const graph = await investigateService.investigateEntity(entity.type, entity.value); - compact.entity = entity; - compact.graph = { nodeCount: graph.nodes.length, edgeCount: graph.edges.length }; + payload.entityGraph = { nodeCount: graph.nodes.length, edgeCount: graph.edges.length }; } catch { /* ignore */ } } - compact.params = { query, description, entity }; return { - content: [{ type: "text" as const, text: JSON.stringify(compact) }], + content: [{ + type: "text" as const, + text: JSON.stringify(createMcpAppBootstrap("threat-hunt", payload)), + }], }; } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "execute-esql", { @@ -113,7 +135,8 @@ export function registerThreatHuntTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "list-indices", { @@ -130,7 +153,8 @@ export function registerThreatHuntTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "get-mapping", { @@ -145,7 +169,8 @@ export function registerThreatHuntTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "get-entity-detail", { @@ -163,7 +188,8 @@ export function registerThreatHuntTools( } ); - registerAppTool( + registerTrackedAppTool( + analytics, server, "investigate-entity", { diff --git a/src/tools/tracked-app-tool.test.ts b/src/tools/tracked-app-tool.test.ts new file mode 100644 index 0000000..29906b2 --- /dev/null +++ b/src/tools/tracked-app-tool.test.ts @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { registerTrackedAppTool } from "./tracked-app-tool.js"; +import { createMockMcpServer } from "../test/helpers/mockMcpServer.js"; +import { createMockAnalyticsClient } from "../test/helpers/mockAnalytics.js"; + +const baseConfig = { + title: "Probe", + description: "test tool", + inputSchema: { value: z.string() }, + _meta: { ui: { resourceUri: "ui://probe/view.html" } }, +} as const; + +function makeOkResult(text: string) { + return { content: [{ type: "text" as const, text }] }; +} + +describe("registerTrackedAppTool", () => { + beforeEach(() => { + vi.spyOn(performance, "now") + .mockReturnValueOnce(1000) + .mockReturnValueOnce(1042); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("emits mcp_tool_called with the recorded duration on a successful resolve", async () => { + const server = createMockMcpServer(); + const analytics = createMockAnalyticsClient(); + const handler = vi.fn(async () => makeOkResult("ok")); + + registerTrackedAppTool( + analytics, + server as unknown as McpServer, + "probe", + baseConfig, + handler, + ); + + const tool = server.tool("probe"); + const out = await tool.callback({ value: "x" }); + + expect(out).toEqual(makeOkResult("ok")); + expect(handler).toHaveBeenCalledOnce(); + expect(analytics.trackToolCalled).toHaveBeenCalledExactlyOnceWith({ + tool_id: "probe", + duration_ms: 42, + success: true, + }); + }); + + it("emits success: false and rethrows when the handler rejects", async () => { + const server = createMockMcpServer(); + const analytics = createMockAnalyticsClient(); + const boom = new Error("handler failed"); + const handler = vi.fn(async () => { + throw boom; + }); + + registerTrackedAppTool( + analytics, + server as unknown as McpServer, + "probe", + baseConfig, + handler, + ); + + const tool = server.tool("probe"); + await expect(tool.callback({ value: "x" })).rejects.toBe(boom); + + expect(analytics.trackToolCalled).toHaveBeenCalledExactlyOnceWith({ + tool_id: "probe", + duration_ms: 42, + success: false, + }); + }); + + it("never mutates the handler's behaviour when telemetry throws (resolve path)", async () => { + const server = createMockMcpServer(); + const analytics = createMockAnalyticsClient(); + (analytics.trackToolCalled as ReturnType).mockImplementation(() => { + throw new Error("telemetry exploded"); + }); + const handler = vi.fn(async () => makeOkResult("payload")); + + registerTrackedAppTool( + analytics, + server as unknown as McpServer, + "probe", + baseConfig, + handler, + ); + + const tool = server.tool("probe"); + const out = await tool.callback({ value: "x" }); + + expect(out).toEqual(makeOkResult("payload")); + }); + + it("never mutates the handler's behaviour when telemetry throws (reject path)", async () => { + const server = createMockMcpServer(); + const analytics = createMockAnalyticsClient(); + (analytics.trackToolCalled as ReturnType).mockImplementation(() => { + throw new Error("telemetry exploded"); + }); + const handlerErr = new Error("handler failed"); + const handler = vi.fn(async () => { + throw handlerErr; + }); + + registerTrackedAppTool( + analytics, + server as unknown as McpServer, + "probe", + baseConfig, + handler, + ); + + const tool = server.tool("probe"); + await expect(tool.callback({ value: "x" })).rejects.toBe(handlerErr); + }); +}); diff --git a/src/tools/tracked-app-tool.ts b/src/tools/tracked-app-tool.ts new file mode 100644 index 0000000..1cd49b5 --- /dev/null +++ b/src/tools/tracked-app-tool.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + registerAppTool, + type McpUiAppToolConfig, +} from "@modelcontextprotocol/ext-apps/server"; +import type { + McpServer, + RegisteredTool, + ToolCallback, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + AnySchema, + ZodRawShapeCompat, +} from "@modelcontextprotocol/sdk/server/zod-compat.js"; +import type { AnalyticsClient } from "../elastic/analytics/index.js"; + +/** + * Drop-in replacement for `registerAppTool` that emits a typed + * `mcp_tool_called` telemetry event for every invocation. + * + * The wrapper: + * - Measures wall-clock duration with `performance.now()`. + * - Reports `success: true` on resolve, `success: false` on reject. + * - **Never** lets a telemetry failure mutate the handler's return + * value or thrown error. The analytics path is wrapped in a + * try/catch and any throw from `trackToolCalled` is swallowed. + * + * The generic signature mirrors `registerAppTool`'s exactly so the + * handler's argument types are still inferred from the `inputSchema`. + * Call sites pass `analytics` first, then the same args as before: + * + * ```ts + * registerTrackedAppTool(analytics, server, "triage-alerts", config, handler); + * ``` + */ +export function registerTrackedAppTool< + OutputArgs extends ZodRawShapeCompat | AnySchema, + InputArgs extends undefined | ZodRawShapeCompat | AnySchema = undefined, +>( + analytics: Pick, + server: Pick, + name: string, + config: McpUiAppToolConfig & { + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + }, + cb: ToolCallback, +): RegisteredTool { + // Treat the callback as an opaque (...args) => result function so we + // don't have to reproduce the exact `args / extra` arity of + // ToolCallback (which differs by whether InputArgs is + // undefined). The runtime contract is "forward whatever you got" — + // the static types are re-applied via the `as unknown as` bridge + // when handing back to `registerAppTool`. + type OpaqueCb = (...args: unknown[]) => unknown; + const original = cb as unknown as OpaqueCb; + + const wrapped: OpaqueCb = (...args) => { + const start = performance.now(); + + const emit = (success: boolean): void => { + try { + analytics.trackToolCalled({ + tool_id: name, + duration_ms: Math.round(performance.now() - start), + success, + }); + } catch { + // Telemetry must never mutate handler behaviour; swallow. + } + }; + + return Promise.resolve(original(...args)).then( + (value) => { + emit(true); + return value; + }, + (err: unknown) => { + emit(false); + throw err; + }, + ); + }; + + return registerAppTool( + server, + name, + config, + wrapped as unknown as ToolCallback, + ); +} diff --git a/src/views/alert-triage/App.tsx b/src/views/alert-triage/App.tsx index 08d9a14..1623419 100644 --- a/src/views/alert-triage/App.tsx +++ b/src/views/alert-triage/App.tsx @@ -8,6 +8,7 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; import type { App as McpApp } from "@modelcontextprotocol/ext-apps"; import { extractToolText, extractCallResult } from "../../shared/extract-tool-text"; +import { inspectMcpAppBootstrapResult } from "../../shared/mcp-app-bootstrap"; import type { SecurityAlert, AlertSummary, AlertContext } from "../../shared/types"; import { AlertCard } from "./components/AlertCard"; import { DetailView } from "./components/DetailView"; @@ -29,7 +30,9 @@ import { useToast, } from "../../shared/components"; import { useFullscreen } from "../../shared/hooks/useFullscreen"; -import { useMcpApp } from "../../shared/hooks/useMcpApp"; +import { useMcpApp, useMcpAppBootstrap, useMcpAppEvents } from "../../shared/hooks/useMcpApp"; +import { McpAppProvider } from "../../shared/hooks/McpAppProvider"; +import { useAnalytics } from "../../shared/hooks/useAnalytics"; import { useAlertSort } from "./hooks/useAlertSort"; import type { GroupKey, SortKey } from "./hooks/useAlertSort"; import "./styles.css"; @@ -75,7 +78,9 @@ const isLimitKey = (v: string): v is LimitKey => export function App() { return ( - + + + ); } @@ -114,10 +119,13 @@ function AppContent() { } }, []); - const { connected, getApp } = useMcpApp({ - name: "alert-triage", - version: "1.0.0", + const { connected, getApp } = useMcpApp(); + const bootstrap = useMcpAppBootstrap("alert-triage"); + useMcpAppEvents({ onToolResult: (result, app) => { + if (inspectMcpAppBootstrapResult(result).status !== "not_bootstrap") { + return; + } try { const text = extractToolText(result); if (text) { @@ -139,11 +147,84 @@ function AppContent() { } catch { /* ignore */ } loadAlertsImpl(app); }, - onConnect: (app, gotResult) => { - if (!gotResult) loadAlertsImpl(app); - }, }); + useEffect(() => { + if (bootstrap.status === "idle") { + return; + } + if (bootstrap.status === "error") { + setLoading(false); + return; + } + const { summary: nextSummary, params, verdicts: nextVerdicts } = bootstrap.payload; + paramsRef.current = { ...params }; + setSummary({ + total: nextSummary.total, + bySeverity: nextSummary.bySeverity, + byRule: [...nextSummary.byRule], + byHost: [...nextSummary.byHost], + alerts: nextSummary.alerts.map((alert) => ({ + _id: alert.id, + _index: "", + _source: { + "@timestamp": alert.timestamp ?? "", + "kibana.alert.rule.name": alert.rule ?? "", + "kibana.alert.rule.uuid": "", + "kibana.alert.severity": alert.severity ?? "low", + "kibana.alert.risk_score": alert.risk_score ?? 0, + "kibana.alert.workflow_status": "open", + "kibana.alert.reason": alert.reason ?? "", + host: alert.host ? { name: alert.host } : undefined, + user: alert.user ? { name: alert.user } : undefined, + process: alert.process + ? { + name: alert.process, + executable: alert.executable, + parent: alert.parent_process ? { name: alert.parent_process } : undefined, + } + : undefined, + file: alert.file ? { path: alert.file } : undefined, + source: alert.source_ip ? { ip: alert.source_ip } : undefined, + destination: alert.dest_ip ? { ip: alert.dest_ip } : undefined, + "kibana.alert.rule.threat": alert.mitre?.map((threat) => ({ + framework: "MITRE ATT&CK", + tactic: { id: "", name: threat.tactic, reference: "" }, + technique: threat.techniques.map((technique) => { + const [id, ...nameParts] = technique.split(" "); + return { + id, + name: nameParts.join(" "), + reference: "", + }; + }), + })), + }, + })), + }); + setVerdicts(nextVerdicts.map((verdict) => ({ + ...verdict, + hosts: verdict.hosts ? [...verdict.hosts] : undefined, + }))); + setSearchInput(params.query ?? ""); + const limKey = String(params.limit); + if (isLimitKey(limKey)) { + setLimit(limKey); + } + setLoading(false); + }, [bootstrap]); + + const { trackEvent } = useAnalytics(); + useEffect(() => { + trackEvent({ eventType: "view_rendered", viewId: "alert-triage" }); + }, [trackEvent]); + + useEffect(() => { + if (!connected || bootstrap.status !== "idle") return; + const app = getApp(); + if (app) loadAlertsImpl(app); + }, [connected, bootstrap.status, getApp, loadAlertsImpl]); + const loadAlerts = useCallback((overrideParams?: Partial) => { const app = getApp(); if (app) loadAlertsImpl(app, overrideParams); @@ -157,9 +238,6 @@ function AppContent() { return () => clearInterval(interval); }, [connected, loadAlerts]); - // Sort + group the current alert summary. The pure helpers and `useMemo` - // wrappers live in `./hooks/useAlertSort` so they can be unit-tested - // independently of this component. const { sortedAlerts, groupedAlerts } = useAlertSort(summary, sortBy, groupBy); const toggleGroup = useCallback((key: string) => { @@ -176,11 +254,6 @@ function AppContent() { setOpenGroups(new Set()); }, []); - // Keep the left list in sync with the selected alert: when navigating to an - // alert from the detail pane (e.g. via the Related list), expand the group - // that contains it (if grouped) and scroll the row into view. `block: "nearest"` - // is a no-op when the row is already visible, so direct clicks in the list - // don't cause unnecessary scrolling. useEffect(() => { const id = selectedAlert?._id; if (!id) return; @@ -382,8 +455,6 @@ function AppContent() { const activeQuery = paramsRef.current.query; const hasDetail = !!selectedAlert; - // verdict lookup removed for stability - const list = ( <> {hasDetail && setSelectedAlert(null)} />} @@ -500,6 +571,8 @@ function AppContent() {
{loading && !summary ? ( Loading alerts... + ) : bootstrap.status === "error" && !summary ? ( + {bootstrap.reason} ) : !summary || summary.alerts.length === 0 ? ( {activeQuery ? `No alerts matching "${activeQuery}"` : "No open alerts"} ) : groupedAlerts ? ( diff --git a/src/views/attack-discovery/App.tsx b/src/views/attack-discovery/App.tsx index 84e77b2..09787a3 100644 --- a/src/views/attack-discovery/App.tsx +++ b/src/views/attack-discovery/App.tsx @@ -9,6 +9,7 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from "react" import type { App as McpApp } from "@modelcontextprotocol/ext-apps"; import { timeAgo } from "../../shared/theme"; import { extractToolText, extractCallResult } from "../../shared/extract-tool-text"; +import { inspectMcpAppBootstrapResult } from "../../shared/mcp-app-bootstrap"; import { SeverityBadge } from "../../shared/severity"; import type { AttackDiscoveryFinding, DiscoveryDetail } from "../../shared/types"; import { AttackFlowDiagram } from "./AttackFlowDiagram"; @@ -37,7 +38,9 @@ import { } from "../../shared/components"; import type { Severity } from "../../shared/components"; import { useFullscreen } from "../../shared/hooks/useFullscreen"; -import { useMcpApp } from "../../shared/hooks/useMcpApp"; +import { useMcpApp, useMcpAppBootstrap, useMcpAppEvents } from "../../shared/hooks/useMcpApp"; +import { McpAppProvider } from "../../shared/hooks/McpAppProvider"; +import { useAnalytics } from "../../shared/hooks/useAnalytics"; import { stripKibanaTemplateSyntax } from "./template-syntax"; import "./styles.css"; @@ -228,7 +231,9 @@ function entityRiskColor(level: string): string { export function App() { return ( - + + + ); } @@ -318,10 +323,13 @@ function AppContent() { } }, [assessConfidence]); - const { connected, getApp } = useMcpApp({ - name: "attack-discovery-triage", - version: "1.0.0", + const { connected, getApp } = useMcpApp(); + const bootstrap = useMcpAppBootstrap("attack-discovery"); + useMcpAppEvents({ onToolResult: (result) => { + if (inspectMcpAppBootstrapResult(result).status !== "not_bootstrap") { + return; + } try { const text = extractToolText(result); if (text) { @@ -339,12 +347,45 @@ function AppContent() { } } catch { /* ignore */ } }, - onConnect: (app, gotResult) => { - if (!gotResult) loadDiscoveriesImpl(app); - checkGenerationStatusImpl(app); - }, }); + useEffect(() => { + if (bootstrap.status === "idle") { + return; + } + if (bootstrap.status === "error") { + setLoading(false); + return; + } + const { discoveries: nextDiscoveries, params } = bootstrap.payload; + paramsRef.current = { ...params }; + setDiscoveries( + nextDiscoveries.map((d) => ({ + ...d, + alertCount: d.alertIds?.length || d.alertCount || 0, + })), + ); + setLoading(false); + }, [bootstrap]); + + useEffect(() => { + if (!connected) return; + const app = getApp(); + if (!app) return; + void checkGenerationStatusImpl(app); + }, [checkGenerationStatusImpl, connected, getApp]); + + const { trackEvent } = useAnalytics(); + useEffect(() => { + trackEvent({ eventType: "view_rendered", viewId: "attack-discovery" }); + }, [trackEvent]); + + useEffect(() => { + if (!connected || bootstrap.status !== "idle") return; + const app = getApp(); + if (app) loadDiscoveriesImpl(app); + }, [connected, bootstrap.status, getApp, loadDiscoveriesImpl]); + const fullscreen = useFullscreen(getApp); const loadDiscoveries = useCallback(() => { @@ -568,8 +609,6 @@ function AppContent() { return sorted; }, [discoveries, activeQuery, confidenceFilter, sortBy]); - // Group filtered discoveries by the selected key. A single discovery can land in multiple - // buckets when grouping by host/user/tactic (since each discovery may reference many of each). const groupedDiscoveries = useMemo(() => { if (groupBy === "none") return null; const buckets = new Map {loading && discoveries.length === 0 ? ( Loading attack discoveries... + ) : bootstrap.status === "error" && discoveries.length === 0 ? ( + {bootstrap.reason} ) : filtered.length === 0 ? (
🛡
diff --git a/src/views/case-management/App.tsx b/src/views/case-management/App.tsx index e9a8775..ae915d0 100644 --- a/src/views/case-management/App.tsx +++ b/src/views/case-management/App.tsx @@ -9,6 +9,7 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from "react" import type { App as McpApp } from "@modelcontextprotocol/ext-apps"; import { timeAgo } from "../../shared/theme"; import { extractToolText, extractCallResult } from "../../shared/extract-tool-text"; +import { inspectMcpAppBootstrapResult } from "../../shared/mcp-app-bootstrap"; import { renderMarkdown } from "../../shared/markdown"; import type { KibanaCase } from "../../shared/types"; import { CaseForm } from "./components/CaseForm"; @@ -39,13 +40,14 @@ import { } from "../../shared/components"; import type { Severity } from "../../shared/components"; import { useFullscreen } from "../../shared/hooks/useFullscreen"; -import { useMcpApp } from "../../shared/hooks/useMcpApp"; +import { useMcpApp, useMcpAppBootstrap, useMcpAppEvents } from "../../shared/hooks/useMcpApp"; +import { McpAppProvider } from "../../shared/hooks/McpAppProvider"; +import { useAnalytics } from "../../shared/hooks/useAnalytics"; import { FactIcon } from "../../shared/components/icons/icons"; import "./styles.css"; type SeverityKey = Severity; type StatusKey = "open" | "in-progress" | "closed"; -/** StatusKey plus the UI-only "all" sentinel used by the filter dropdown. */ type StatusFilterKey = StatusKey | "all"; type SortKey = "severity" | "newest" | "oldest" | "title" | "alerts" | "comments"; type GroupKey = "none" | "status" | "severity" | "creator" | "tag"; @@ -130,7 +132,9 @@ function normalizeCase(raw: unknown): KibanaCase | null { export function App() { return ( - + + + ); } @@ -181,10 +185,13 @@ function AppContent() { } }, []); - const { connected, getApp } = useMcpApp({ - name: "case-management", - version: "1.0.0", + const { connected, getApp } = useMcpApp(); + const bootstrap = useMcpAppBootstrap("case-management"); + useMcpAppEvents({ onToolResult: (result, app) => { + if (inspectMcpAppBootstrapResult(result).status !== "not_bootstrap") { + return; + } try { const text = extractToolText(result); if (text) { @@ -203,11 +210,39 @@ function AppContent() { } catch { /* ignore */ } loadCasesImpl(app); }, - onConnect: (app, gotResult) => { - if (!gotResult) loadCasesImpl(app); - }, }); + useEffect(() => { + if (bootstrap.status === "idle") { + return; + } + if (bootstrap.status === "error") { + setLoading(false); + return; + } + const { cases: nextCases, total: nextTotal, params } = bootstrap.payload; + setCases(nextCases.map(normalizeCase).filter(Boolean) as KibanaCase[]); + setTotal(nextTotal); + paramsRef.current = { + status: params.status, + search: params.search, + }; + setStatusFilter((params.status as StatusFilterKey | undefined) ?? "open"); + setSearchInput(params.search ?? ""); + setLoading(false); + }, [bootstrap]); + + const { trackEvent } = useAnalytics(); + useEffect(() => { + trackEvent({ eventType: "view_rendered", viewId: "case-management" }); + }, [trackEvent]); + + useEffect(() => { + if (!connected || bootstrap.status !== "idle") return; + const app = getApp(); + if (app) loadCasesImpl(app); + }, [connected, bootstrap.status, getApp, loadCasesImpl]); + const fullscreen = useFullscreen(getApp); const toast = useToast(); @@ -322,8 +357,6 @@ function AppContent() { return arr; }, [cases, sortBy]); - // Group cases into buckets by the selected grouping key. Each bucket carries a display - // name, optional subtitle, the highest-severity case in the group, and the cases themselves. const groupedCases = useMemo(() => { if (groupBy === "none") return null; const buckets = new Map { const d = (SEV_RANK[b.topSeverity] || 0) - (SEV_RANK[a.topSeverity] || 0); if (d !== 0) return d; @@ -483,6 +515,8 @@ function AppContent() {
{loading && !cases.length ? ( Loading cases… + ) : bootstrap.status === "error" && !cases.length ? ( + {bootstrap.reason} ) : isCreating ? ( Fill in the form on the right to create a case. ) : !cases.length ? ( @@ -644,8 +678,6 @@ function AppContent() { ); } -// ─── Card ───────────────────────────────────────────────────────────────────── - function CaseCard({ caseData, compact, selected, showDetails = true, onClick, onFilter }: { caseData: KibanaCase; compact?: boolean; selected?: boolean; showDetails?: boolean; onClick?: () => void; onFilter?: (q: string) => void; }) { @@ -738,8 +770,6 @@ function CaseCard({ caseData, compact, selected, showDetails = true, onClick, on ); } -// ─── Detail view ───────────────────────────────────────────────────────────── - const ALERTS_PREVIEW = 3; const COMMENTS_PREVIEW = 3; @@ -938,7 +968,6 @@ function FactCol({ label, value, icon, onFilter }: { label: string; value?: stri ); } - function ExpandSection({ title, count, expanded, onToggle, previewCount, children }: { title: string; count: number; expanded: boolean; onToggle: () => void; previewCount: number; children: React.ReactNode; }) { diff --git a/src/views/detection-rules/App.tsx b/src/views/detection-rules/App.tsx index 51e5961..71b03a9 100644 --- a/src/views/detection-rules/App.tsx +++ b/src/views/detection-rules/App.tsx @@ -5,9 +5,10 @@ * 2.0. */ -import React, { useState, useCallback, useMemo } from "react"; +import React, { useState, useCallback, useMemo, useEffect } from "react"; import type { App as McpApp } from "@modelcontextprotocol/ext-apps"; import { extractToolText, extractCallResult } from "../../shared/extract-tool-text"; +import { inspectMcpAppBootstrapResult } from "../../shared/mcp-app-bootstrap"; import type { DetectionRule } from "../../shared/types"; import { RuleTestPanel } from "./components/RuleTestPanel"; import { FactCol } from "./components/FactCol"; @@ -35,7 +36,9 @@ import { } from "../../shared/components"; import type { Severity } from "../../shared/components"; import { useFullscreen } from "../../shared/hooks/useFullscreen"; -import { useMcpApp } from "../../shared/hooks/useMcpApp"; +import { useMcpApp, useMcpAppBootstrap, useMcpAppEvents } from "../../shared/hooks/useMcpApp"; +import { McpAppProvider } from "../../shared/hooks/McpAppProvider"; +import { useAnalytics } from "../../shared/hooks/useAnalytics"; import "./styles.css"; type SeverityKey = Severity; @@ -74,6 +77,14 @@ const GROUP_LABEL: Record = Object.fromEntries( ) as Record; export function App() { + return ( + + + + ); +} + +function AppContent() { const [rules, setRules] = useState([]); const [total, setTotal] = useState(0); const [selectedRule, setSelectedRule] = useState(null); @@ -109,10 +120,13 @@ export function App() { } }, []); - const { connected, getApp } = useMcpApp({ - name: "detection-rules", - version: "1.0.0", + const { connected, getApp } = useMcpApp(); + const bootstrap = useMcpAppBootstrap("detection-rules"); + useMcpAppEvents({ onToolResult: (params, app) => { + if (inspectMcpAppBootstrapResult(params).status !== "not_bootstrap") { + return; + } try { const text = extractToolText(params); if (text) { @@ -125,11 +139,69 @@ export function App() { } catch { /* ignore */ } loadRulesImpl(app); }, - onConnect: (app, gotResult) => { - if (!gotResult) loadRulesImpl(app); - }, }); + useEffect(() => { + if (bootstrap.status === "idle") { + return; + } + if (bootstrap.status === "error") { + setListLoading(false); + return; + } + const { rules: nextRules, total: nextTotal, params } = bootstrap.payload; + setRules(nextRules.map((rule) => ({ + id: rule.id, + rule_id: rule.id, + name: rule.name, + description: rule.description ?? "", + severity: + rule.severity === "medium" || + rule.severity === "high" || + rule.severity === "critical" || + rule.severity === "low" + ? rule.severity + : "low", + risk_score: rule.risk_score ?? 0, + type: rule.type ?? "", + enabled: rule.enabled ?? false, + query: rule.query, + language: rule.language, + tags: rule.tags ? [...rule.tags] : undefined, + threat: rule.threat?.map((threat) => ({ + framework: "MITRE ATT&CK", + tactic: { id: "", name: threat.tactic ?? "", reference: "" }, + technique: threat.techniques.map((technique) => { + const [id, ...nameParts] = technique.split(" "); + return { + id, + name: nameParts.join(" "), + reference: "", + }; + }), + })), + created_at: "", + updated_at: "", + created_by: "", + }))); + setTotal(nextTotal); + const filter = params.filter ?? ""; + setSearchInput(filter); + setActiveFilter(filter); + setListLoading(false); + }, [bootstrap]); + + const { trackEvent } = useAnalytics(); + useEffect(() => { + trackEvent({ eventType: "view_rendered", viewId: "detection-rules" }); + }, [trackEvent]); + + useEffect(() => { + if (!connected || bootstrap.status !== "idle") return; + const app = getApp(); + if (app) loadRulesImpl(app); + }, [connected, bootstrap.status, getApp, loadRulesImpl]); + const loadRules = useCallback((filter?: string) => { const app = getApp(); if (app) loadRulesImpl(app, filter); @@ -238,8 +310,6 @@ export function App() { return sorted; }, [rules, statusFilter, sortBy]); - // Group filtered rules by the selected key. A rule can land in multiple buckets - // when grouping by tag (since a rule may carry several tags). const groupedRules = useMemo(() => { if (groupBy === "none") return null; const buckets = new Map {listLoading && !rules.length ? ( Loading rules… + ) : bootstrap.status === "error" && !rules.length ? ( + {bootstrap.reason} ) : !rules.length ? ( {activeFilter ? `No rules matching "${activeFilter}"` : "No rules available"} ) : groupedRules ? ( @@ -503,8 +575,6 @@ export function App() { ); } -// ─── Rule Card ─────────────────────────────────────────────────────────────── - function RuleCard({ rule, compact, selected, showDetails = true, onClick, onToggle }: { rule: DetectionRule; compact?: boolean; selected?: boolean; showDetails?: boolean; onClick?: () => void; onToggle?: (enabled: boolean) => void; @@ -589,8 +659,6 @@ function RuleCard({ rule, compact, selected, showDetails = true, onClick, onTogg ); } -// ─── Rule Detail View ─────────────────────────────────────────────────────── - function RuleDetailView({ rule, onToggle, onValidate }: { rule: DetectionRule; onToggle: (enabled: boolean) => void; diff --git a/src/views/sample-data/App.tsx b/src/views/sample-data/App.tsx index f782137..0e60e7b 100644 --- a/src/views/sample-data/App.tsx +++ b/src/views/sample-data/App.tsx @@ -5,12 +5,18 @@ * 2.0. */ -import React, { useState, useCallback, useMemo } from "react"; +import React, { useState, useCallback, useMemo, useEffect } from "react"; import { App as McpApp } from "@modelcontextprotocol/ext-apps"; import { extractCallResult } from "../../shared/extract-tool-text"; +import { + inspectMcpAppBootstrapResult, + type SampleDataExistingData, +} from "../../shared/mcp-app-bootstrap"; import { SeverityChip } from "../../shared/components"; +import { useMcpApp, useMcpAppBootstrap, useMcpAppEvents } from "../../shared/hooks/useMcpApp"; +import { McpAppProvider } from "../../shared/hooks/McpAppProvider"; +import { useAnalytics } from "../../shared/hooks/useAnalytics"; import { AppGlyph, SearchIcon } from "../../shared/components/icons/icons"; -import { useMcpApp } from "../../shared/hooks/useMcpApp"; import "./styles.css"; interface AlertInfo { @@ -491,6 +497,14 @@ const SEVERITY_FILTERS: { key: SeverityFilter; label: string }[] = [ ]; export function App() { + return ( + + + + ); +} + +function AppContent() { const [selected, setSelected] = useState>(new Set()); const [expanded, setExpanded] = useState(null); const [count, setCount] = useState(50); @@ -502,7 +516,7 @@ export function App() { const [errorMsg, setErrorMsg] = useState(null); const [cleanupCount, setCleanupCount] = useState(null); const [rulesCreated, setRulesCreated] = useState(0); - const [existingData, setExistingData] = useState<{ totalDocs: number; totalAlerts: number; existingRules: number; byScenario: Record } | null>(null); + const [existingData, setExistingData] = useState(null); const [severityFilter, setSeverityFilter] = useState("all"); const [searchInput, setSearchInput] = useState(""); @@ -514,10 +528,13 @@ export function App() { } catch { /* cluster might not be reachable */ } }, []); - const { connected, getApp } = useMcpApp({ - name: "sample-data", - version: "1.0.0", + const { connected, getApp } = useMcpApp(); + const bootstrap = useMcpAppBootstrap("sample-data"); + useMcpAppEvents({ onToolResult: (toolResult) => { + if (inspectMcpAppBootstrapResult(toolResult).status !== "not_bootstrap") { + return; + } try { const text = extractCallResult(toolResult); if (text) { @@ -528,11 +545,26 @@ export function App() { } } catch { /* ignore */ } }, - onConnect: (app) => { - loadExistingData(app); - }, }); + useEffect(() => { + if (bootstrap.status !== "ready") { + return; + } + setExistingData(bootstrap.payload.existingData); + }, [bootstrap]); + + const { trackEvent } = useAnalytics(); + useEffect(() => { + trackEvent({ eventType: "view_rendered", viewId: "sample-data" }); + }, [trackEvent]); + + useEffect(() => { + if (!connected || bootstrap.status !== "idle") return; + const app = getApp(); + if (app) loadExistingData(app); + }, [connected, bootstrap.status, getApp, loadExistingData]); + const toggleScenario = useCallback((id: string) => { setSelected((prev) => { const next = new Set(prev); diff --git a/src/views/threat-hunt/App.tsx b/src/views/threat-hunt/App.tsx index 83d2506..38822e3 100644 --- a/src/views/threat-hunt/App.tsx +++ b/src/views/threat-hunt/App.tsx @@ -5,23 +5,57 @@ * 2.0. */ -import React, { useCallback, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { extractToolText, extractCallResult } from "../../shared/extract-tool-text"; +import { inspectMcpAppBootstrapResult } from "../../shared/mcp-app-bootstrap"; import type { EsqlResult } from "../../shared/types"; import { useFullscreen } from "../../shared/hooks/useFullscreen"; -import { useMcpApp } from "../../shared/hooks/useMcpApp"; +import { useMcpApp, useMcpAppBootstrap, useMcpAppEvents } from "../../shared/hooks/useMcpApp"; +import { McpAppProvider } from "../../shared/hooks/McpAppProvider"; +import { useAnalytics } from "../../shared/hooks/useAnalytics"; import { AppGlyph, FullscreenIcon, ExitFullscreenIcon } from "../../shared/components/icons/icons"; import { QueryEditor } from "./components/QueryEditor"; import { ResultsTable } from "./components/ResultsTable"; -// TODO: re-enable the force-directed Network view once it's stable. -// import { InvestigationGraph } from "./components/InvestigationGraph"; import type { GNode, GEdge } from "./components/InvestigationGraph"; import { CardGraph } from "./components/CardGraph"; import "./styles.css"; +const DEFAULT_QUERY = `FROM logs-* +| WHERE host.name == "win-dc-01" +| STATS count = COUNT(*) BY user.name, process.name +| SORT count DESC +| LIMIT 10`; + +const DEFAULT_RESULTS: EsqlResult = { + columns: [ + { name: "user.name", type: "keyword" }, + { name: "process.name", type: "keyword" }, + { name: "host.name", type: "keyword" }, + { name: "count", type: "long" }, + ], + values: [ + ["svc_backup", "powershell.exe", "win-dc-01", 147], + ["svc_backup", "procdump.exe", "win-dc-01", 42], + ["admin.backup", "powershell.exe", "win-dc-01", 38], + ["svc_backup", "cmd.exe", "win-dc-01", 29], + ["admin.backup", "rundll32.exe", "win-dc-01", 21], + ["svc_backup", "net.exe", "win-dc-01", 17], + ["admin.backup", "wmic.exe", "win-dc-01", 14], + ["svc_backup", "reg.exe", "win-dc-01", 11], + ], +}; + export function App() { - const [query, setQuery] = useState(""); - const [results, setResults] = useState(null); + return ( + + + + ); +} + +function AppContent() { + const [query, setQuery] = useState(DEFAULT_QUERY); + const [results, setResults] = useState(DEFAULT_RESULTS); const [queryError, setQueryError] = useState(null); const [executing, setExecuting] = useState(false); const [hasExecuted, setHasExecuted] = useState(false); @@ -29,16 +63,13 @@ export function App() { const [graphNodes, setGraphNodes] = useState([]); const [graphEdges, setGraphEdges] = useState([]); const [graphActive, setGraphActive] = useState(false); - // The force-directed "Network" view is hidden for now — it gets messy fast - // when hunting across many alerts/users/hosts. We default to the Cards view - // and leave the toggle out of the UI until the network layout is improved. const [selectedNode, setSelectedNode] = useState(null); const [nodeDetail, setNodeDetail] = useState | null>(null); const [nodeDetailLoading, setNodeDetailLoading] = useState(false); // Pending query/entity references survive across renders so the - // ontoolresult callback can stash work that came in before connect() - // resolved, and the onConnect callback can flush it. + // ontoolresult callback can stash work that came in before the + // explicit bootstrap payload or transport connect effect flushes it. const pendingRef = useRef<{ query: string | null; entity: { type: string; value: string } | null }>({ query: null, entity: null, @@ -50,10 +81,13 @@ export function App() { // need to call flush) and the closures themselves (which need `getApp`). const flushPendingRef = useRef<() => void>(() => {}); - const { connected, getApp } = useMcpApp({ - name: "threat-hunt", - version: "1.0.0", + const { connected, getApp } = useMcpApp(); + const bootstrap = useMcpAppBootstrap("threat-hunt"); + useMcpAppEvents({ onToolResult: (result) => { + if (inspectMcpAppBootstrapResult(result).status !== "not_bootstrap") { + return; + } try { const text = extractToolText(result); if (text) { @@ -70,13 +104,44 @@ export function App() { } catch { /* ignore */ } flushPendingRef.current(); }, - onConnect: () => { - // Final flush after the grace window — picks up anything that arrived - // before connect() resolved. - flushPendingRef.current(); - }, }); + useEffect(() => { + if (bootstrap.status !== "ready") { + return; + } + const { params, queryResult, queryError, entityGraph } = bootstrap.payload; + if (params.query) { + const q = params.query.trim(); + setQuery(q); + pendingRef.current.query = q; + } + if (params.entity) { + pendingRef.current.entity = params.entity; + } + if (queryResult) { + setResults({ + columns: queryResult.columns.map((name) => ({ name, type: "keyword" })), + values: queryResult.rows.map((row) => [...row]), + }); + setQueryError(null); + setHasExecuted(true); + } else if (queryError) { + setResults(null); + setQueryError(queryError); + setHasExecuted(true); + } + if (entityGraph) { + setGraphActive(entityGraph.nodeCount > 0 || entityGraph.edgeCount > 0); + } + flushPendingRef.current(); + }, [bootstrap]); + + const { trackEvent } = useAnalytics(); + useEffect(() => { + trackEvent({ eventType: "view_rendered", viewId: "threat-hunt" }); + }, [trackEvent]); + const fullscreen = useFullscreen(getApp); const executeQuery = useCallback(async (q: string) => { @@ -172,15 +237,6 @@ export function App() { finally { setNodeDetailLoading(false); } }, []); - /** - * Seed the investigation graph with a pre-built example so users can see the - * visualization without having to click entities in a results table. This is - * especially useful when the view is driven by a playbook that only issues - * ES|QL queries — the graph pane would otherwise stay hidden. The example - * mirrors the "domain-controller compromise" storyline used in the fixtures: - * a host hub with user/process/IP/alert neighbors and a lateral pivot to a - * second host. - */ const loadExampleInvestigation = useCallback(() => { const rootId = "host:win-dc-01"; const nodes: GNode[] = [ @@ -208,11 +264,6 @@ export function App() { setSelectedNode(null); }, []); - // collapseEntity was used by the hidden InvestigationGraph (force-directed) - // view; restore alongside that component when re-enabling the Network view. - - // Bind the live flush function so the `useMcpApp` callbacks declared above - // can call it via `flushPendingRef.current()` without TDZ issues. flushPendingRef.current = () => { const pending = pendingRef.current; if (pending.entity) { @@ -300,15 +351,11 @@ export function App() {
- {/* Filter (ES|QL query editor) sits directly under the header. */} executeQuery(query)} executing={executing} /> {queryError &&
{queryError}
} - {/* Visualization (on top) + results table share a single bordered block. */}
{graphActive && (() => { - // Title shows "Exploring: " where root is the first expanded - // node (or the first node if none are expanded). Matches Figma 3-3041. const rootNode = graphNodes.find((n) => n.expanded) || graphNodes[0]; return (
@@ -357,8 +404,6 @@ export function App() { ); } -/* ─── Node Detail Panel ─── */ - const TYPE_LABELS: Record = { alert: { icon: "\u26A0", label: "Alert", color: "#f04040" }, user: { icon: "\u{1F464}", label: "User", color: "#5c7cfa" }, diff --git a/tsconfig.server.json b/tsconfig.server.json index fb0edee..7cda632 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -13,7 +13,7 @@ "rootDir": ".", "lib": ["ES2022"] }, - "include": ["main.ts", "src/server.ts", "src/elastic/**/*", "src/tools/**/*", "src/shared/types.ts"], + "include": ["main.ts", "src/server.ts", "src/elastic/**/*", "src/tools/**/*", "src/shared/logger.ts", "src/shared/types.ts"], "exclude": [ "node_modules", "src/views/**/*",