@bli-cockpit/mcp 0.1.87 → 0.1.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -109,7 +109,7 @@ about what an agent can reach.
109
109
 
110
110
  <!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->
111
111
 
112
- **124 of 129 Tower verbs have an MCP twin.**
112
+ **125 of 130 Tower verbs have an MCP twin.**
113
113
  Each tool goes through the SAME door its CLI verb calls, with the same
114
114
  collector device token — never a second route and never a service-role
115
115
  reader. `src/verb-census.test.ts` fails when a verb is in none of the
@@ -169,6 +169,7 @@ three tables below.
169
169
  | `cockpit issue update` | `work_update_issue` | `PATCH /api/work/issues/[id]` |
170
170
  | `cockpit jarvis` | `jarvis_ask` | `POST /api/jarvis/cli` |
171
171
  | `cockpit jarvis --trace` | `jarvis_trace` | `GET /api/ops/trace/[id]` |
172
+ | `cockpit logs` | `logs_digest` | `LOCAL @bli-cockpit/telemetry-core/log-digest (this machine's logs); `project` reads GET /api/logs (the stored digests)` |
172
173
  | `cockpit mail accounts` | `mail_accounts` | `GET /api/mail/accounts` |
173
174
  | `cockpit mail attachment` | `mail_attachment` | `GET /api/mail/attachments/[id]?meta=1` |
174
175
  | `cockpit mail detach` | `mail_detach` | `DELETE /api/mail/accounts` |
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `logs_digest` — the twin of `cockpit logs` (BLI-12833).
3
+ *
4
+ * A token-cheap digest of Tower's own logs, read on THIS machine: the same
5
+ * `digestSource` from `@bli-cockpit/telemetry-core/log-digest` the CLI calls, so the two print
6
+ * the same text. Variable parts of each line are masked into a template, equal
7
+ * templates are counted, routine runs become counts, and every template is
8
+ * classified: `our_bug` and `unknown` first, harmless ones counted on one line.
9
+ *
10
+ * It is local and read-only. Sources: `collector` (this machine's sync logs),
11
+ * `dashboard` (Vercel runtime logs through the `vercel` CLI), `railway` and
12
+ * `railway:<service>` (through the `railway` CLI), and `-` with the lines passed
13
+ * as `text`. A CLI that is missing or not logged in is a named refusal
14
+ * (`vercel_cli_missing`, `railway_not_logged_in`), never a stack trace.
15
+ *
16
+ * `project` (BLI-12855) reads the digests Tower's own 15-minute cron STORED for a
17
+ * BLI project (`kolm`, `tower`, ...) through the dashboard's `GET /api/logs` door,
18
+ * the same door `cockpit logs <project>` calls: no `vercel` or `railway` login, no
19
+ * local file. A `dashboard` or `railway` read whose local CLI is missing or logged
20
+ * out falls back to the stored digest of project `tower` and says so.
21
+ *
22
+ * Not on the hosted server: it reads the machine's files and spawns programs
23
+ * there (`REMOTE_EXCLUDED_TOOLS`).
24
+ */
25
+ import { digestSource, type DigestOutcome } from "@bli-cockpit/telemetry-core/log-digest";
26
+ import { type ToolDeps } from "./tool-result.js";
27
+ export interface LogsToolDeps extends Partial<ToolDeps> {
28
+ /** Test seam: replaces the digest. */
29
+ digest?: (...args: Parameters<typeof digestSource>) => Promise<DigestOutcome>;
30
+ /** Test seam: where the collector's state lives; defaults to this machine's. */
31
+ stateDir?: string;
32
+ }
33
+ export declare function registerLogsTools(server: {
34
+ registerTool: (...args: never[]) => unknown;
35
+ }, deps?: LogsToolDeps): void;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `logs_digest` — the twin of `cockpit logs` (BLI-12833).
3
+ *
4
+ * A token-cheap digest of Tower's own logs, read on THIS machine: the same
5
+ * `digestSource` from `@bli-cockpit/telemetry-core/log-digest` the CLI calls, so the two print
6
+ * the same text. Variable parts of each line are masked into a template, equal
7
+ * templates are counted, routine runs become counts, and every template is
8
+ * classified: `our_bug` and `unknown` first, harmless ones counted on one line.
9
+ *
10
+ * It is local and read-only. Sources: `collector` (this machine's sync logs),
11
+ * `dashboard` (Vercel runtime logs through the `vercel` CLI), `railway` and
12
+ * `railway:<service>` (through the `railway` CLI), and `-` with the lines passed
13
+ * as `text`. A CLI that is missing or not logged in is a named refusal
14
+ * (`vercel_cli_missing`, `railway_not_logged_in`), never a stack trace.
15
+ *
16
+ * `project` (BLI-12855) reads the digests Tower's own 15-minute cron STORED for a
17
+ * BLI project (`kolm`, `tower`, ...) through the dashboard's `GET /api/logs` door,
18
+ * the same door `cockpit logs <project>` calls: no `vercel` or `railway` login, no
19
+ * local file. A `dashboard` or `railway` read whose local CLI is missing or logged
20
+ * out falls back to the stored digest of project `tower` and says so.
21
+ *
22
+ * Not on the hosted server: it reads the machine's files and spawns programs
23
+ * there (`REMOTE_EXCLUDED_TOOLS`).
24
+ */
25
+ import os from "node:os";
26
+ import { LOCAL_ACCESS_REFUSALS, LOG_PROJECT_PATTERN, digestSource, routineText } from "@bli-cockpit/telemetry-core/log-digest";
27
+ import { getUserLocalCockpitPaths } from "@bli-cockpit/telemetry-core";
28
+ import { z } from "zod";
29
+ import { callAgentDoor } from "./agent-door.js";
30
+ import { doorFailureText, errorResult, registrarFor, textResult, withSession } from "./tool-result.js";
31
+ /** Past this the structured half would crowd the 32 KB per-half read budget. */
32
+ const MAX_TEMPLATES_RETURNED = 40;
33
+ const MAX_TEXT_CHARS = 256_000;
34
+ export function registerLogsTools(server, deps = {}) {
35
+ const digest = deps.digest ?? digestSource;
36
+ const door = { fetchImpl: deps.fetchImpl ?? fetch, ...(deps.loadSession ? { loadSession: deps.loadSession } : {}) };
37
+ registrarFor(server)("logs_digest", {
38
+ title: "Digest Tower's logs (about 2 KB)",
39
+ description: "A token-cheap digest of Tower's own logs, read on THIS machine and never uploaded. Use it BEFORE reading raw logs: a 15-minute window of a "
40
+ + "busy source is tens of thousands of lines; this returns about 2 KB. Each line's variable parts are masked into a template, equal templates "
41
+ + "are counted with first and last time, routine runs become counts, and every template is classified (our_bug and unknown first, then "
42
+ + "capacity, config_drift, rate_pushback, hiccup; dead_source and benign are counted on one line and never alerted). Sources: `collector` "
43
+ + "(this machine's sync.log and sync.err.log), `dashboard` (Vercel runtime logs through the `vercel` CLI), `railway` (every Tower service) "
44
+ + "or `railway:<service>` (through the `railway` CLI), `-` (lines passed as `text`). `since` is 15m, 1h, 2d or an ISO time (default 15m). "
45
+ + "Pass `raw` (an 8-hex template id from a digest line) to get the original lines behind that template, redacted, newest last. A missing or "
46
+ + "logged-out CLI is a named refusal (vercel_cli_missing, vercel_not_logged_in, railway_cli_missing, railway_not_logged_in), except that "
47
+ + "`dashboard` and `railway` then fall back to the digest Tower STORED. Pass `project` (tower, kolm, portpal, pearl-portal, triage-crm, "
48
+ + "triage-ocr, blue-pearl-website, kaiben, black-knight-tire, sublimation-squad, bli-website) instead of `source` to read what Tower's "
49
+ + "15-minute cron stored from that project's Vercel, Railway and Supabase logs, merged over `since` (default 24h, at most 30d), no login "
50
+ + "needed: the answer names the sources it could not see. A stored digest keeps no raw line, so `raw` does not apply. `cockpit logs`, same code.",
51
+ inputSchema: {
52
+ source: z.string().min(1).max(80).optional().describe("collector, dashboard, railway, railway:<service> or - (lines in `text`). Omit when `project` is given."),
53
+ project: z.string().regex(LOG_PROJECT_PATTERN).optional().describe("A BLI project id: read the digest Tower's cron stored for it (no local CLI needed). Takes `since`; `raw` does not apply."),
54
+ since: z.string().min(1).max(40).optional().describe("Window: 15m, 1h, 2d or an ISO time. Default 15m."),
55
+ raw: z.string().regex(/^[0-9a-f]{8}$/).optional().describe("A template id from a digest line: return the original lines behind it."),
56
+ limit: z.number().int().min(1).max(200).optional().describe("With raw: how many original lines. Default 20."),
57
+ all_levels: z.boolean().optional().describe("dashboard only: also read info-only requests (slow, minutes)."),
58
+ text: z.string().max(MAX_TEXT_CHARS).optional().describe("The lines to digest when source is -."),
59
+ },
60
+ }, async (args) => answer(digest, deps, door, args));
61
+ }
62
+ /** The stored digest of a project, through `GET /api/logs` with this machine's device token. */
63
+ async function storedAnswer(door, project, since, fallbackReason) {
64
+ return withSession(door, async (session) => {
65
+ const query = new URLSearchParams({ project });
66
+ if (since)
67
+ query.set("since", since);
68
+ const response = await callAgentDoor(session, door.fetchImpl, "GET", `/api/logs?${query.toString()}`);
69
+ if (!response.ok)
70
+ return errorResult(doorFailureText("logs_digest", response));
71
+ const body = response.body;
72
+ const text = typeof body["text"] === "string" ? body["text"] : "";
73
+ return textResult(fallbackReason ? `(no local access: ${fallbackReason}; this is the digest Tower stored)\n${text}` : text, {
74
+ ...body,
75
+ ...(fallbackReason ? { fallback_from: fallbackReason } : {}),
76
+ });
77
+ });
78
+ }
79
+ async function answer(digest, deps, door, args) {
80
+ const since = typeof args["since"] === "string" ? args["since"] : undefined;
81
+ if (typeof args["project"] === "string") {
82
+ if (typeof args["raw"] === "string")
83
+ return errorResult("logs_digest refused (raw_not_stored): a stored digest keeps no raw line; raw reads a local source.");
84
+ return storedAnswer(door, args["project"], since);
85
+ }
86
+ if (typeof args["source"] !== "string")
87
+ return errorResult("logs_digest refused (source_or_project_required): pass `source` (a local log) or `project` (a stored digest).");
88
+ const source = args["source"];
89
+ const text = typeof args["text"] === "string" ? args["text"] : undefined;
90
+ if (source === "-" && !text) {
91
+ return errorResult('logs_digest refused (stdin_empty): source "-" digests the lines you pass in `text`; none were given.');
92
+ }
93
+ const outcome = await digest({
94
+ source,
95
+ since,
96
+ raw: typeof args["raw"] === "string" ? args["raw"] : undefined,
97
+ rawLimit: typeof args["limit"] === "number" ? args["limit"] : undefined,
98
+ allLevels: args["all_levels"] === true,
99
+ }, { stateDir: deps.stateDir ?? getUserLocalCockpitPaths(os.homedir()).state_dir, stdinText: text, stdinIsTerminal: false });
100
+ if (!outcome.ok && LOCAL_ACCESS_REFUSALS.has(outcome.reason) && (source === "dashboard" || source === "railway") && typeof args["raw"] !== "string") {
101
+ const stored = await storedAnswer(door, "tower", since, outcome.reason);
102
+ // No pairing either: the local refusal is the honest answer.
103
+ return stored.isError ? errorResult(`logs_digest refused (${outcome.reason}): ${outcome.detail} The stored digest could not be read either: ${stored.content[0]?.text ?? "unknown"}`) : stored;
104
+ }
105
+ if (!outcome.ok)
106
+ return errorResult(`logs_digest refused (${outcome.reason}): ${outcome.detail}`);
107
+ if (outcome.kind === "raw") {
108
+ return textResult(outcome.text, { kind: "raw", source: outcome.source, tid: outcome.tid, window: outcome.window, matched: outcome.matched, lines: outcome.lines });
109
+ }
110
+ return textResult(outcome.text, {
111
+ kind: "digest",
112
+ source: outcome.source,
113
+ window: outcome.window,
114
+ lines: outcome.lines,
115
+ noise: outcome.noise,
116
+ notes: outcome.notes,
117
+ measure: outcome.measure,
118
+ templates_total: outcome.templates.length,
119
+ templates: outcome.templates.slice(0, MAX_TEMPLATES_RETURNED),
120
+ work: routineText(outcome.routine, 12),
121
+ });
122
+ }
package/dist/server.js CHANGED
@@ -34,6 +34,7 @@ import { registerWorkTools } from "./work-tools.js";
34
34
  import { registerCareersTools } from "./careers-tools.js";
35
35
  import { registerRenameTools } from "./rename-tools.js";
36
36
  import { registerUsageTools } from "./usage-tools.js";
37
+ import { registerLogsTools } from "./logs-tools.js";
37
38
  import { registerSourcesTools } from "./sources-tools.js";
38
39
  import { TOWER_SERVER_ICONS } from "./server-icons.js";
39
40
  import { installToolRegistration } from "./tool-registration.js";
@@ -500,5 +501,8 @@ export function createServer(deps, options = {}) {
500
501
  // `cockpit sources check` on this machine. No delete twin, ever: Tower never
501
502
  // deletes a person's transcripts and an agent must not do it through a tool.
502
503
  registerSourcesTools(server);
504
+ // BLI-12833: a token-cheap digest of Tower's own logs, read on this machine
505
+ // with the shared `@bli-cockpit/telemetry-core/log-digest`. Read-only; not on the hosted server.
506
+ registerLogsTools(server, { fetchImpl: deps.fetchImpl });
503
507
  return server;
504
508
  }
@@ -36,6 +36,7 @@ export const REMOTE_EXCLUDED_TOOLS = {
36
36
  get_active_tickets: "legacy event stream; authenticates with an operator token the host does not hold",
37
37
  notes_upload: "reads a file by path from the machine the server runs on; use notes_paste with the text",
38
38
  sources_check: "spawns `cockpit sources check` on the machine the server runs on",
39
+ logs_digest: "reads the machine's collector logs and spawns the vercel and railway CLIs on the machine the server runs on",
39
40
  };
40
41
  const READS = new Set(READ_TOOLS);
41
42
  const DESTRUCTIVE = new Set(DESTRUCTIVE_TOOLS);
@@ -10,7 +10,7 @@
10
10
  * from `src/` under Node's type stripping, so it may import nothing.
11
11
  */
12
12
  /** Tools that change nothing. The smoke calls every one. */
13
- export declare const READ_TOOLS: readonly ["get_ticket_timeline", "get_active_tickets", "docs_list", "docs_tree", "docs_read", "docs_sync_status", "msg_channels", "msg_read", "msg_thread", "work_list_issues", "work_get_issue", "work_issue_history", "work_list_projects", "careers_list", "careers_show", "careers_resume_url", "careers_takehome_show", "careers_settings_show", "rename_pending", "github_pr", "usage_people", "usage_sessions", "mail_accounts", "mail_inbox", "mail_read", "mail_search", "mail_attachment", "mail_sync_status", "cal_today", "cal_week", "cal_next", "cal_find", "cal_calendars", "cal_sync_status", "jarvis_ask", "jarvis_trace", "jarvis_check", "models_list", "models_card", "models_compare", "brief_read", "brief_history", "brief_status", "brief_missed", "notes_folders", "notes_list", "notes_show", "notes_shelf", "notes_shelves", "notes_place", "notes_accounts", "notes_sync_status", "notes_granola_status", "ops_status", "slack_coverage", "slack_read", "memory_profile", "memory_consolidation", "settings_show", "settings_list", "model_show", "team_members", "team_device_list", "scout_board", "workbook_read", "tower_search", "sources_check"];
13
+ export declare const READ_TOOLS: readonly ["get_ticket_timeline", "get_active_tickets", "docs_list", "docs_tree", "docs_read", "docs_sync_status", "msg_channels", "msg_read", "msg_thread", "work_list_issues", "work_get_issue", "work_issue_history", "work_list_projects", "careers_list", "careers_show", "careers_resume_url", "careers_takehome_show", "careers_settings_show", "rename_pending", "github_pr", "usage_people", "usage_sessions", "mail_accounts", "mail_inbox", "mail_read", "mail_search", "mail_attachment", "mail_sync_status", "cal_today", "cal_week", "cal_next", "cal_find", "cal_calendars", "cal_sync_status", "jarvis_ask", "jarvis_trace", "jarvis_check", "models_list", "models_card", "models_compare", "brief_read", "brief_history", "brief_status", "brief_missed", "notes_folders", "notes_list", "notes_show", "notes_shelf", "notes_shelves", "notes_place", "notes_accounts", "notes_sync_status", "notes_granola_status", "ops_status", "slack_coverage", "slack_read", "memory_profile", "memory_consolidation", "settings_show", "settings_list", "model_show", "team_members", "team_device_list", "scout_board", "workbook_read", "tower_search", "sources_check", "logs_digest"];
14
14
  /**
15
15
  * Tools that write, but answer a dry run that writes nothing. The smoke calls
16
16
  * each one ONLY with its dry-run flag set (`scripts/smoke.ts` says which).
@@ -78,6 +78,8 @@ export const READ_TOOLS = [
78
78
  "workbook_read",
79
79
  "tower_search",
80
80
  "sources_check",
81
+ // BLI-12833: a digest of Tower's own logs, read on this machine. Reads only.
82
+ "logs_digest",
81
83
  ];
82
84
  /**
83
85
  * Tools that write, but answer a dry run that writes nothing. The smoke calls
@@ -239,6 +239,10 @@ export const MCP_TWINS = {
239
239
  "github merge": { tool: "github_merge", door: "POST /api/github/pull-request (merge)" },
240
240
  "github retitle": { tool: "github_retitle", door: "POST /api/github/pull-request (retitle)" },
241
241
  "github rerun": { tool: "github_rerun", door: "POST /api/github/pull-request (rerun)" },
242
+ // BLI-12833: a local read, not a Tower door: it runs the shared log digest over
243
+ // this machine's collector logs and the `vercel` / `railway` CLIs.
244
+ // BLI-12855: a project name (or --stored, or a local CLI that is missing) reads the digests Tower's cron stored, through GET /api/logs.
245
+ "logs": { tool: "logs_digest", door: "LOCAL @bli-cockpit/telemetry-core/log-digest (this machine's logs); `project` reads GET /api/logs (the stored digests)" },
242
246
  "usage sessions": { tool: "usage_sessions", door: "GET /api/usage/sessions" },
243
247
  "usage people": { tool: "usage_people", door: "GET /api/usage/people" },
244
248
  "docs list": { tool: "docs_list", door: "GET /api/docs/documents" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.87",
3
+ "version": "0.1.90",
4
4
  "private": false,
5
5
  "description": "bli-tower: an MCP server over BLI Cockpit's agent doors: JARVIS (jarvis_*), documents (docs_*), channels (msg_*), issues (work_*), the daily page (brief_*), meeting notes (notes_*), the ops board (ops_status/slack_*), settings/team/model, Scout and the workbook, plus the legacy event-stream tools (emit_event, get_ticket_timeline, get_active_tickets).",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "readme": "npm run build && node scripts/write-readme-census.mjs"
38
38
  },
39
39
  "dependencies": {
40
- "@bli-cockpit/telemetry-core": "0.1.58",
40
+ "@bli-cockpit/telemetry-core": "0.1.60",
41
41
  "@modelcontextprotocol/sdk": "^1.29.0",
42
42
  "zod": "^4.3.6"
43
43
  },