@bli-cockpit/mcp 0.1.0

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 ADDED
@@ -0,0 +1,213 @@
1
+ # @bli-cockpit/mcp — bli-tower
2
+
3
+ Two, deliberately separate, tool families over one MCP server (bin
4
+ `bli-cockpit-mcp`, server id `bli-tower`):
5
+
6
+ 1. **`docs_*`/`msg_*` (BLI-3706)** — the SAME `/api/docs/**` and `/api/msg/**`
7
+ agent doors `cockpit docs`/`cockpit msg` call, authenticated with this
8
+ machine's collector device token (`~/.config/bli-cockpit/session.json`,
9
+ written by `cockpit login`). This is the live, actively-developed half.
10
+ 2. **`emit_event`/`get_ticket_timeline`/`get_active_tickets`** — the legacy
11
+ event-stream write path, authenticated with `BLI_OPERATOR_TOKEN` or the
12
+ `bli-event` session helper. See "Legacy event-stream tools" below.
13
+
14
+ The two halves use DIFFERENT credentials and DIFFERENT base URLs on purpose
15
+ (`docs-msg-tools.ts` talks to the production Tower dashboard; the event-stream
16
+ tools talk to `BLI_API_BASE_URL`, historically a local worker) — see
17
+ `docs/architecture/cli-caller-identity.md` for why `docs_*`/`msg_*` needed no
18
+ new server-side door: every `/api/docs/**`/`/api/msg/**` route already accepts
19
+ a collector device token.
20
+
21
+ ## `docs_*` / `msg_*` tools (BLI-3706)
22
+
23
+ | Tool | Wraps | Notes |
24
+ | --- | --- | --- |
25
+ | `docs_list` | `GET /api/docs/documents` | Every document you may read: id, slug, title, visibility. Never a body. |
26
+ | `docs_read` | `GET /api/docs/documents/[id]` | One document's title and full body, by id or slug (resolved against `docs_list` first). |
27
+ | `docs_create` | `POST /api/docs/documents` | Creates a document. Omitting `body_markdown` creates an empty one. |
28
+ | `docs_update` | `PATCH /api/docs/documents/[id]` | Updates title/body/visibility/parent (a parent change IS a move). |
29
+ | `msg_channels` | `GET /api/msg/channels` | Every channel you are a member of. |
30
+ | `msg_read` | `GET /api/msg/channels/[id]/messages` | Recent messages of one channel, or one thread's replies. `channel` is an id or a `#name`. |
31
+ | `msg_send` | `POST /api/msg/channels/[id]/messages` | Posts a message. |
32
+
33
+ **Auth: this machine's collector device token, not `BLI_OPERATOR_TOKEN`.**
34
+ Run `cockpit login` once; the server reads
35
+ `~/.config/bli-cockpit/session.json` fresh on every call (never cached at
36
+ startup, so a machine with no pairing yet still serves the event-stream tools
37
+ below — only a `docs_*`/`msg_*` call on that machine fails, by name). Two
38
+ optional env overrides, mirroring `@bli-cockpit/memory-mcp`'s own:
39
+
40
+ | Var | Required | Purpose |
41
+ | --- | --- | --- |
42
+ | `BLI_COCKPIT_MCP_DEVICE_TOKEN` | no | Override the device token (skips the session file). |
43
+ | `BLI_COCKPIT_MCP_DASHBOARD_URL` | no | Override the dashboard base URL (defaults to production, or `COCKPIT_DASHBOARD_URL` if set). |
44
+
45
+ A door refusal or a network failure both come back as an MCP `isError` result
46
+ naming the door's own `reason` label (e.g. `document_not_found_or_unreadable`)
47
+ — never a generic "something went wrong".
48
+
49
+ ## Registration on an intern machine
50
+
51
+ `cockpit memory install` (run unasked by `do-everything` and the daily sync
52
+ tick) registers `bli-tower` beside `bli-memory` in the SAME two files —
53
+ `~/.claude.json`'s `mcpServers` and `~/.codex/config.toml`'s
54
+ `[mcp_servers.bli-tower]` table — resolving this package's own bin the same
55
+ way it resolves `bli-memory-mcp`'s (beside the running `@bli-cockpit/cli`
56
+ install, then PATH). `bli-tower` has no Claude Code hooks and no Codex skill,
57
+ so nothing is written to `~/.claude/settings.json` for it. See
58
+ `packages/cockpit-local-collector/src/commands/tower-mcp-install.ts`.
59
+
60
+ ## Legacy event-stream tools
61
+
62
+ Thin wrapper over the canonical REST event-stream contracts (see
63
+ `docs/plans/cockpit-agent-ops-control-plane.md` §6) — the FROZEN active-emission
64
+ substrate (`AGENTS.md` "What this repo is"), not where new work goes.
65
+
66
+ | Tool | Wraps | Notes |
67
+ | --- | --- | --- |
68
+ | `emit_event` | `POST /api/events/emit` | Appends a canonical event. Supports `idempotency_key`. |
69
+ | `get_ticket_timeline` | `GET /api/events/timeline?ticket_id=...` | Returns the canonical timeline for a task-visible ticket. |
70
+ | `get_active_tickets` | `GET /api/tickets/active?...` | Returns active, task-visible tickets derived from `orchestration_events`. |
71
+
72
+ ### `emit_event`
73
+
74
+ | Field | Type | Required | Notes |
75
+ | --- | --- | --- | --- |
76
+ | `ticket_id` | string | yes | Linear ticket ID, e.g. `BUI-412`. |
77
+ | `event_type` | string | yes | Canonical event type (e.g. `worker_dispatched`, `note`). |
78
+ | `payload` | object | no (default `{}`) | Event payload. |
79
+ | `note_md` | string | no | Optional markdown note. |
80
+ | `correlation_id` | string | no | E.g. an agent trace ID. |
81
+ | `idempotency_key` | string | no | Replays return `status: "replay"`. |
82
+
83
+ Returns `{ event_id, occurred_at, status: "created" | "replay" }`.
84
+
85
+ ### `get_ticket_timeline`
86
+
87
+ | Field | Type | Required | Notes |
88
+ | --- | --- | --- | --- |
89
+ | `ticket_id` | string | yes | |
90
+ | `since` | string (ISO-8601) | no | |
91
+ | `limit` | number (1–500) | no (default 100) | |
92
+
93
+ The API resolves `ticket_id` through `tasks.linear_ticket_id` and denies
94
+ callers who cannot see that task.
95
+
96
+ ### `get_active_tickets`
97
+
98
+ All four filters (`source`, `project`, `lane`, `assignee`) optional strings.
99
+ The API derives active work from `orchestration_events`, resolves tickets back
100
+ to `tasks.linear_ticket_id`, and returns only tasks visible to the caller.
101
+
102
+ ## Environment variables (legacy event-stream tools only)
103
+
104
+ `docs_*`/`msg_*`'s own two optional overrides are documented above, beside
105
+ those tools — this table is `emit_event`/`get_ticket_timeline`/
106
+ `get_active_tickets`'s, a separate credential and base URL.
107
+
108
+ | Var | Required | Default | Purpose |
109
+ | --- | --- | --- | --- |
110
+ | `BLI_OPERATOR_TOKEN` | no | — | If set, used verbatim as the bearer token on every request. Intended for CI. When unset, the server falls back to the session helper. |
111
+ | `BLI_SESSION_HELPER` | no | auto-discovered | Absolute path to `scripts/bli-event-session.mjs`. Only needed if the MCP binary lives outside the bli-cockpit repo layout. |
112
+ | `BLI_API_BASE_URL` | no | `http://127.0.0.1:3100` | Cockpit dashboard base URL. |
113
+
114
+ ## Authentication (legacy event-stream tools only)
115
+
116
+ The server resolves an access_token before every authenticated request using the same precedence as the `bli-event` CLI:
117
+
118
+ 1. `BLI_OPERATOR_TOKEN` (verbatim, no refresh) — CI path.
119
+ 2. Session helper — shells out to `node scripts/bli-event-session.mjs get-token`, which reads `~/.config/bli-event/session.json` and refreshes the access_token when near expiry. Run `bli-event login` once on your laptop to create the session. Tokens are cached in-process for 30s to avoid spawning a subprocess on every tool call.
120
+
121
+ If both paths are unavailable (no env override, and no session file on disk), the server exits at startup with an actionable error.
122
+
123
+ ## Install
124
+
125
+ ```bash
126
+ npm install # from repo root — registers the workspace
127
+ npm run build --workspace=@bli-cockpit/mcp
128
+ ```
129
+
130
+ The `bin` entry exposes a `bli-cockpit-mcp` CLI that speaks MCP over stdio.
131
+
132
+ ### Claude Code (`~/.claude/settings.json`)
133
+
134
+ ```jsonc
135
+ {
136
+ "mcpServers": {
137
+ "bli-cockpit": {
138
+ "command": "node",
139
+ "args": [
140
+ "/absolute/path/to/bli-cockpit/packages/bli-cockpit-mcp/dist/index.js"
141
+ ],
142
+ "env": {
143
+ "BLI_API_BASE_URL": "http://127.0.0.1:3100"
144
+ }
145
+ }
146
+ }
147
+ }
148
+ ```
149
+
150
+ Run `bli-event login` once on the host; the MCP auto-resolves the
151
+ access_token from `~/.config/bli-event/session.json` and refreshes it
152
+ transparently.
153
+
154
+ For CI (or any environment without a session file), set
155
+ `BLI_OPERATOR_TOKEN` to a Supabase access_token in the env block — it
156
+ will be used verbatim without refresh.
157
+
158
+ For deployed cockpits, set `BLI_API_BASE_URL` to the production URL.
159
+ Never commit tokens — keep them in local settings only.
160
+
161
+ ### Claude Desktop
162
+
163
+ Same shape, but the file lives at:
164
+ - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
165
+ - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
166
+
167
+ ## Example invocations
168
+
169
+ ```jsonc
170
+ // emit a worker dispatch event
171
+ {
172
+ "name": "emit_event",
173
+ "arguments": {
174
+ "ticket_id": "BUI-412",
175
+ "event_type": "worker_dispatched",
176
+ "payload": { "role": "builder", "model": "sonnet" },
177
+ "correlation_id": "trace_abc123"
178
+ }
179
+ }
180
+
181
+ // idempotent note from a hook
182
+ {
183
+ "name": "emit_event",
184
+ "arguments": {
185
+ "ticket_id": "BUI-412",
186
+ "event_type": "note",
187
+ "note_md": "ship completed; preview URL https://...",
188
+ "idempotency_key": "ship-2026-04-17-1"
189
+ }
190
+ }
191
+ ```
192
+
193
+ ## Errors
194
+
195
+ The server surfaces API error codes from the canonical catalog
196
+ (`docs/architecture/event-stream.md#errors`) as MCP tool errors. Common ones:
197
+
198
+ | Code | Cause |
199
+ | --- | --- |
200
+ | `BLI-E001` | Network / Supabase unreachable. |
201
+ | `BLI-E002` | Unknown `event_type`. |
202
+ | `BLI-E003` | Bad / expired JWT. |
203
+ | `BLI-E004` | Payload schema validation failed. |
204
+ | `BLI-E403` | Authenticated caller cannot access the requested ticket. |
205
+ | `BLI-W003` | Idempotency replay (informational; not an error). |
206
+
207
+ ## Development
208
+
209
+ ```bash
210
+ npm run typecheck --workspace=@bli-cockpit/mcp
211
+ npm test --workspace=@bli-cockpit/mcp
212
+ npm run build --workspace=@bli-cockpit/mcp
213
+ ```
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Who is this machine, for the docs/msg tools (BLI-3706).
3
+ *
4
+ * `emit_event`/`get_ticket_timeline`/`get_active_tickets` in this same server
5
+ * authenticate with `BLI_OPERATOR_TOKEN` or the `bli-event-session.mjs` helper
6
+ * (`token-resolver.ts`) — a bearer JWT against the legacy event-stream worker.
7
+ * `/api/docs/**` and `/api/msg/**` are a different door entirely
8
+ * (`resolveCaller({ allowDeviceToken: true })` on the dashboard): they take the
9
+ * collector's own `bli_dev_*` device token, the credential `cockpit login`
10
+ * already wrote to `~/.config/bli-cockpit/session.json`. This module reads
11
+ * that file directly rather than depending on `@bli-cockpit/local-collector`
12
+ * or `@bli-cockpit/memory-mcp` — this package does not declare either as a
13
+ * dependency, and `packages/cockpit-memory-mcp/src/session.ts` already reads
14
+ * the identical file for the identical reason (its own header explains why: a
15
+ * package installed on its own should invent no fourth credential). The two
16
+ * copies are intentionally near-identical; see this file's own header in the
17
+ * PR that added it for the duplication call.
18
+ *
19
+ * The token is never logged, printed, or included in an error.
20
+ */
21
+ export declare const SESSION_RELATIVE_PATH: string[];
22
+ export declare const DEFAULT_DASHBOARD_URL = "https://bli-cockpit-dashboard.vercel.app";
23
+ export interface AgentDoorSession {
24
+ dashboardUrl: string;
25
+ deviceToken: string;
26
+ }
27
+ export type LoadAgentDoorSessionResult = {
28
+ ok: true;
29
+ session: AgentDoorSession;
30
+ } | {
31
+ ok: false;
32
+ reason: "session_file_missing" | "session_file_unreadable" | "session_file_invalid" | "session_missing_token";
33
+ message: string;
34
+ };
35
+ export declare function agentDoorSessionFilePath(homeDir?: string): string;
36
+ export declare function loadAgentDoorSession(options?: {
37
+ env?: NodeJS.ProcessEnv;
38
+ homeDir?: string;
39
+ }): LoadAgentDoorSessionResult;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Who is this machine, for the docs/msg tools (BLI-3706).
3
+ *
4
+ * `emit_event`/`get_ticket_timeline`/`get_active_tickets` in this same server
5
+ * authenticate with `BLI_OPERATOR_TOKEN` or the `bli-event-session.mjs` helper
6
+ * (`token-resolver.ts`) — a bearer JWT against the legacy event-stream worker.
7
+ * `/api/docs/**` and `/api/msg/**` are a different door entirely
8
+ * (`resolveCaller({ allowDeviceToken: true })` on the dashboard): they take the
9
+ * collector's own `bli_dev_*` device token, the credential `cockpit login`
10
+ * already wrote to `~/.config/bli-cockpit/session.json`. This module reads
11
+ * that file directly rather than depending on `@bli-cockpit/local-collector`
12
+ * or `@bli-cockpit/memory-mcp` — this package does not declare either as a
13
+ * dependency, and `packages/cockpit-memory-mcp/src/session.ts` already reads
14
+ * the identical file for the identical reason (its own header explains why: a
15
+ * package installed on its own should invent no fourth credential). The two
16
+ * copies are intentionally near-identical; see this file's own header in the
17
+ * PR that added it for the duplication call.
18
+ *
19
+ * The token is never logged, printed, or included in an error.
20
+ */
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ export const SESSION_RELATIVE_PATH = [".config", "bli-cockpit", "session.json"];
25
+ export const DEFAULT_DASHBOARD_URL = "https://bli-cockpit-dashboard.vercel.app";
26
+ export function agentDoorSessionFilePath(homeDir = os.homedir()) {
27
+ return path.join(homeDir, ...SESSION_RELATIVE_PATH);
28
+ }
29
+ export function loadAgentDoorSession(options = {}) {
30
+ const env = options.env ?? process.env;
31
+ const envToken = (env.BLI_COCKPIT_MCP_DEVICE_TOKEN ?? "").trim();
32
+ if (envToken.length > 0) {
33
+ return {
34
+ ok: true,
35
+ session: {
36
+ deviceToken: envToken,
37
+ dashboardUrl: normalizeUrl(env.BLI_COCKPIT_MCP_DASHBOARD_URL ?? env.COCKPIT_DASHBOARD_URL ?? ""),
38
+ },
39
+ };
40
+ }
41
+ const file = agentDoorSessionFilePath(options.homeDir ?? env.HOME ?? os.homedir());
42
+ let raw;
43
+ try {
44
+ raw = fs.readFileSync(file, "utf8");
45
+ }
46
+ catch (error) {
47
+ const missing = error?.code === "ENOENT";
48
+ return {
49
+ ok: false,
50
+ reason: missing ? "session_file_missing" : "session_file_unreadable",
51
+ message: missing
52
+ ? "This machine is not paired with Tower. Run `cockpit login` once, then restart your agent."
53
+ : "The Tower session file exists but could not be read. Run `cockpit login` again.",
54
+ };
55
+ }
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(raw);
59
+ }
60
+ catch {
61
+ return { ok: false, reason: "session_file_invalid", message: "The Tower session file is not valid JSON. Run `cockpit login` again." };
62
+ }
63
+ const record = parsed && typeof parsed === "object" ? parsed : {};
64
+ const deviceToken = typeof record.device_token === "string" ? record.device_token.trim() : "";
65
+ if (deviceToken.length === 0) {
66
+ return { ok: false, reason: "session_missing_token", message: "The Tower session file carries no device token. Run `cockpit login` again." };
67
+ }
68
+ const fileUrl = typeof record.dashboard_url === "string" ? record.dashboard_url : "";
69
+ return {
70
+ ok: true,
71
+ session: {
72
+ deviceToken,
73
+ dashboardUrl: normalizeUrl(env.BLI_COCKPIT_MCP_DASHBOARD_URL ?? env.COCKPIT_DASHBOARD_URL ?? fileUrl),
74
+ },
75
+ };
76
+ }
77
+ function normalizeUrl(value) {
78
+ const trimmed = value.trim().replace(/\/+$/, "");
79
+ return trimmed.length > 0 ? trimmed : DEFAULT_DASHBOARD_URL;
80
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * One call to `/api/docs/**` or `/api/msg/**` with this machine's device
3
+ * token (BLI-3706). Deadline-bounded; a transport failure and a door refusal
4
+ * are kept apart, same rule as `packages/cockpit-memory-mcp/src/door.ts`.
5
+ */
6
+ import type { AgentDoorSession } from "./agent-door-session.js";
7
+ export type FetchImpl = typeof fetch;
8
+ export interface AgentDoorResponse {
9
+ ok: boolean;
10
+ status: number;
11
+ body: Record<string, unknown>;
12
+ /** Set only when no JSON answer was produced at all — DNS, TLS, timeout, non-JSON body. */
13
+ transportError: string | null;
14
+ }
15
+ export declare function callAgentDoor(session: AgentDoorSession, fetchImpl: FetchImpl, method: "GET" | "POST" | "PATCH", path: string, body?: unknown, timeoutMs?: number): Promise<AgentDoorResponse>;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * One call to `/api/docs/**` or `/api/msg/**` with this machine's device
3
+ * token (BLI-3706). Deadline-bounded; a transport failure and a door refusal
4
+ * are kept apart, same rule as `packages/cockpit-memory-mcp/src/door.ts`.
5
+ */
6
+ const DEFAULT_TIMEOUT_MS = 15_000;
7
+ export async function callAgentDoor(session, fetchImpl, method, path, body, timeoutMs = DEFAULT_TIMEOUT_MS) {
8
+ const url = `${session.dashboardUrl}${path}`;
9
+ const controller = new AbortController();
10
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
11
+ timer.unref?.();
12
+ let response;
13
+ try {
14
+ response = await fetchImpl(url, {
15
+ method,
16
+ headers: {
17
+ authorization: `Bearer ${session.deviceToken}`,
18
+ "content-type": "application/json",
19
+ accept: "application/json",
20
+ },
21
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
22
+ signal: controller.signal,
23
+ });
24
+ }
25
+ catch (error) {
26
+ const timedOut = error?.name === "AbortError";
27
+ return {
28
+ ok: false,
29
+ status: 0,
30
+ body: {},
31
+ transportError: timedOut
32
+ ? `Tower did not answer within ${timeoutMs} ms; the request was abandoned.`
33
+ : error instanceof Error
34
+ ? error.message.split("\n")[0] ?? "unknown"
35
+ : String(error),
36
+ };
37
+ }
38
+ finally {
39
+ clearTimeout(timer);
40
+ }
41
+ let parsed = {};
42
+ try {
43
+ const text = await response.text();
44
+ parsed = text ? JSON.parse(text) : {};
45
+ }
46
+ catch {
47
+ return {
48
+ ok: false,
49
+ status: response.status,
50
+ body: {},
51
+ transportError: `Tower answered ${response.status} with a body this server could not read as JSON.`,
52
+ };
53
+ }
54
+ return { ok: response.ok, status: response.status, body: parsed, transportError: null };
55
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `docs_*` and `msg_*` MCP tools (BLI-3706) — the same `/api/docs/**` and
3
+ * `/api/msg/**` doors `cockpit docs`/`cockpit msg` call, over this machine's
4
+ * device token (`agent-door-session.ts`/`agent-door.ts`).
5
+ *
6
+ * Every tool loads the session fresh per call (a cheap file read) rather than
7
+ * once at server startup: unlike BLI Memory's server, THIS package still
8
+ * starts and serves `emit_event`/`get_ticket_timeline`/`get_active_tickets`
9
+ * on a machine with no collector pairing at all, so a missing session must
10
+ * fail the ONE call that needed it, never the whole process.
11
+ */
12
+ import { loadAgentDoorSession } from "./agent-door-session.js";
13
+ import { type FetchImpl } from "./agent-door.js";
14
+ export interface DocsMsgDeps {
15
+ fetchImpl: FetchImpl;
16
+ /** Injectable for tests; defaults to reading `~/.config/bli-cockpit/session.json`. */
17
+ loadSession?: typeof loadAgentDoorSession;
18
+ }
19
+ export declare function registerDocsMsgTools(server: {
20
+ registerTool: (...args: never[]) => unknown;
21
+ }, deps: DocsMsgDeps): void;
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `docs_*` and `msg_*` MCP tools (BLI-3706) — the same `/api/docs/**` and
3
+ * `/api/msg/**` doors `cockpit docs`/`cockpit msg` call, over this machine's
4
+ * device token (`agent-door-session.ts`/`agent-door.ts`).
5
+ *
6
+ * Every tool loads the session fresh per call (a cheap file read) rather than
7
+ * once at server startup: unlike BLI Memory's server, THIS package still
8
+ * starts and serves `emit_event`/`get_ticket_timeline`/`get_active_tickets`
9
+ * on a machine with no collector pairing at all, so a missing session must
10
+ * fail the ONE call that needed it, never the whole process.
11
+ */
12
+ import { z } from "zod";
13
+ import { loadAgentDoorSession } from "./agent-door-session.js";
14
+ import { callAgentDoor } from "./agent-door.js";
15
+ function textResult(text, structured) {
16
+ return { content: [{ type: "text", text }], ...(structured ? { structuredContent: structured } : {}) };
17
+ }
18
+ function errorResult(text) {
19
+ return { isError: true, content: [{ type: "text", text }] };
20
+ }
21
+ async function withSession(deps, run) {
22
+ const loadSession = deps.loadSession ?? loadAgentDoorSession;
23
+ const loaded = loadSession();
24
+ if (!loaded.ok)
25
+ return errorResult(`This machine is not paired with Tower (${loaded.reason}). ${loaded.message}`);
26
+ return run(loaded.session);
27
+ }
28
+ function doorFailureText(door, response) {
29
+ if (response.transportError) {
30
+ return `Tower could not be reached for ${door} (${response.transportError}). Nothing was read or written.`;
31
+ }
32
+ const reason = typeof response.body.reason === "string" ? response.body.reason : typeof response.body.error === "string" ? response.body.error : "unknown_error";
33
+ const message = typeof response.body.message === "string" ? response.body.message : `Tower answered ${response.status}.`;
34
+ return `Tower refused ${door} (${reason}): ${message}`;
35
+ }
36
+ async function resolveDocumentId(deps, session, ref) {
37
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
38
+ if (!response.ok)
39
+ return { status: "list_failed", text: doorFailureText("docs_list", response) };
40
+ const documents = (Array.isArray(response.body.documents) ? response.body.documents : []);
41
+ const match = documents.find((doc) => doc.id === ref || doc.slug === ref);
42
+ return match ? { status: "ok", id: match.id } : { status: "not_found" };
43
+ }
44
+ async function resolveChannelId(deps, session, ref) {
45
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/msg/channels");
46
+ if (!response.ok)
47
+ return { status: "list_failed", text: doorFailureText("msg_channels", response) };
48
+ const channels = (Array.isArray(response.body.channels) ? response.body.channels : []);
49
+ const bare = ref.startsWith("#") ? ref.slice(1) : ref;
50
+ const match = channels.find((channel) => channel.id === ref || channel.name === bare);
51
+ return match ? { status: "ok", id: match.id } : { status: "not_found" };
52
+ }
53
+ export function registerDocsMsgTools(server, deps) {
54
+ const register = server.registerTool.bind(server);
55
+ register("docs_list", {
56
+ title: "List Tower documents",
57
+ description: "Every document you may read: id, slug, title, visibility, source. Never a document's body — call docs_read for that.",
58
+ inputSchema: {},
59
+ }, async () => withSession(deps, async (session) => {
60
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
61
+ if (!response.ok)
62
+ return errorResult(doorFailureText("docs_list", response));
63
+ const documents = response.body.documents ?? [];
64
+ return textResult(`${documents.length} document(s).`, { documents });
65
+ }));
66
+ register("docs_read", {
67
+ title: "Read a Tower document",
68
+ description: "One document's title and full body, by its id or slug (docs_list returns both).",
69
+ inputSchema: { id: z.string().min(1).max(200).describe("A document id, or its slug.") },
70
+ }, async (args) => withSession(deps, async (session) => {
71
+ const ref = String(args.id ?? "");
72
+ const resolved = await resolveDocumentId(deps, session, ref);
73
+ if (resolved.status === "list_failed")
74
+ return errorResult(resolved.text);
75
+ if (resolved.status === "not_found")
76
+ return errorResult(`No document matches "${ref}" — check docs_list for the id or slug.`);
77
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/docs/documents/${encodeURIComponent(resolved.id)}`);
78
+ if (!response.ok)
79
+ return errorResult(doorFailureText("docs_read", response));
80
+ const document = response.body.document;
81
+ return textResult(`${document?.title ?? ""}\n\n${document?.body_markdown ?? ""}`, { document });
82
+ }));
83
+ register("docs_create", {
84
+ title: "Create a Tower document",
85
+ description: "Creates a new document. Omitting body creates an empty one, matching the browser's own default.",
86
+ inputSchema: {
87
+ title: z.string().min(1).max(200),
88
+ body_markdown: z.string().max(200_000).optional(),
89
+ parent_id: z.string().uuid().optional(),
90
+ visibility: z.enum(["org", "private"]).optional(),
91
+ },
92
+ }, async (args) => withSession(deps, async (session) => {
93
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/docs/documents", {
94
+ title: args.title,
95
+ body_markdown: args.body_markdown ?? "",
96
+ ...(args.parent_id ? { parent_id: args.parent_id } : {}),
97
+ ...(args.visibility ? { visibility: args.visibility } : {}),
98
+ });
99
+ if (!response.ok)
100
+ return errorResult(doorFailureText("docs_create", response));
101
+ const document = response.body.document;
102
+ return textResult(`Created "${document?.title ?? ""}" (${document?.id ?? ""}).`, { document });
103
+ }));
104
+ register("docs_update", {
105
+ title: "Update a Tower document",
106
+ description: "Updates a document's title, body, visibility, or parent (a parent change IS a move). Only the fields you pass change.",
107
+ inputSchema: {
108
+ id: z.string().min(1).max(200).describe("A document id, or its slug."),
109
+ title: z.string().min(1).max(200).optional(),
110
+ body_markdown: z.string().max(200_000).optional(),
111
+ visibility: z.enum(["org", "private"]).optional(),
112
+ parent_id: z.string().uuid().nullable().optional(),
113
+ },
114
+ }, async (args) => withSession(deps, async (session) => {
115
+ const ref = String(args.id ?? "");
116
+ const resolved = await resolveDocumentId(deps, session, ref);
117
+ if (resolved.status === "list_failed")
118
+ return errorResult(resolved.text);
119
+ if (resolved.status === "not_found")
120
+ return errorResult(`No document matches "${ref}" — check docs_list for the id or slug.`);
121
+ const response = await callAgentDoor(session, deps.fetchImpl, "PATCH", `/api/docs/documents/${encodeURIComponent(resolved.id)}`, {
122
+ ...(args.title !== undefined ? { title: args.title } : {}),
123
+ ...(args.body_markdown !== undefined ? { body_markdown: args.body_markdown } : {}),
124
+ ...(args.visibility !== undefined ? { visibility: args.visibility } : {}),
125
+ ...(args.parent_id !== undefined ? { parent_id: args.parent_id } : {}),
126
+ });
127
+ if (!response.ok)
128
+ return errorResult(doorFailureText("docs_update", response));
129
+ const document = response.body.document;
130
+ return textResult(`Updated "${document?.title ?? ""}" (${document?.id ?? resolved.id}).`, { document });
131
+ }));
132
+ register("msg_channels", {
133
+ title: "List Tower channels",
134
+ description: "Every channel you are a member of.",
135
+ inputSchema: {},
136
+ }, async () => withSession(deps, async (session) => {
137
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/msg/channels");
138
+ if (!response.ok)
139
+ return errorResult(doorFailureText("msg_channels", response));
140
+ const channels = response.body.channels ?? [];
141
+ return textResult(`${channels.length} channel(s).`, { channels });
142
+ }));
143
+ register("msg_read", {
144
+ title: "Read a Tower channel",
145
+ description: "Recent messages of one channel, newest first. `channel` is a channel id, or its name with or without a leading #.",
146
+ inputSchema: {
147
+ channel: z.string().min(1).max(200),
148
+ limit: z.number().int().min(1).max(200).optional(),
149
+ },
150
+ }, async (args) => withSession(deps, async (session) => {
151
+ const ref = String(args.channel ?? "");
152
+ const resolved = await resolveChannelId(deps, session, ref);
153
+ if (resolved.status === "list_failed")
154
+ return errorResult(resolved.text);
155
+ if (resolved.status === "not_found")
156
+ return errorResult(`No channel matches "${ref}" — check msg_channels for the id or name.`);
157
+ const query = args.limit ? `?limit=${args.limit}` : "";
158
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/msg/channels/${encodeURIComponent(resolved.id)}/messages${query}`);
159
+ if (!response.ok)
160
+ return errorResult(doorFailureText("msg_read", response));
161
+ const messages = response.body.messages ?? [];
162
+ return textResult(`${messages.length} message(s) in ${ref}.`, { channelId: resolved.id, messages });
163
+ }));
164
+ register("msg_send", {
165
+ title: "Send a Tower message",
166
+ description: "Posts a message to one channel. `channel` is a channel id, or its name with or without a leading #.",
167
+ inputSchema: {
168
+ channel: z.string().min(1).max(200),
169
+ content: z.string().min(1).max(8_000),
170
+ thread_parent_id: z.string().uuid().optional(),
171
+ },
172
+ }, async (args) => withSession(deps, async (session) => {
173
+ const ref = String(args.channel ?? "");
174
+ const resolved = await resolveChannelId(deps, session, ref);
175
+ if (resolved.status === "list_failed")
176
+ return errorResult(resolved.text);
177
+ if (resolved.status === "not_found")
178
+ return errorResult(`No channel matches "${ref}" — check msg_channels for the id or name.`);
179
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", `/api/msg/channels/${encodeURIComponent(resolved.id)}/messages`, {
180
+ content: args.content,
181
+ ...(args.thread_parent_id ? { thread_parent_id: args.thread_parent_id } : {}),
182
+ });
183
+ if (!response.ok)
184
+ return errorResult(doorFailureText("msg_send", response));
185
+ const message = response.body.message;
186
+ return textResult(`Sent to ${ref} (${message?.id ?? "?"}).`, { message });
187
+ }));
188
+ }
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @bli-cockpit/mcp — MCP server adapter for the BLI Cockpit event stream.
4
+ *
5
+ * Exposes three tools backed by the canonical REST contract at
6
+ * `POST /api/events/emit` (see docs/plans/cockpit-agent-ops-control-plane.md §6).
7
+ *
8
+ * Auth (precedence order, matches the `bli-event` bash CLI):
9
+ * 1. `BLI_OPERATOR_TOKEN` env var — used verbatim (CI / override).
10
+ * 2. Otherwise: shell out to `node scripts/bli-event-session.mjs get-token`
11
+ * which reads `~/.config/bli-event/session.json`, refreshes the
12
+ * access_token if near expiry, and prints a fresh token on stdout.
13
+ * Set `BLI_SESSION_HELPER` to an absolute path if the helper can't be
14
+ * discovered by walking up from this module's location.
15
+ *
16
+ * If neither path is available, the server prints a clear error and exits.
17
+ */
18
+ export {};