@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 +213 -0
- package/dist/agent-door-session.d.ts +39 -0
- package/dist/agent-door-session.js +80 -0
- package/dist/agent-door.d.ts +15 -0
- package/dist/agent-door.js +55 -0
- package/dist/docs-msg-tools.d.ts +21 -0
- package/dist/docs-msg-tools.js +188 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +50 -0
- package/dist/server.d.ts +96 -0
- package/dist/server.js +374 -0
- package/dist/token-resolver.d.ts +116 -0
- package/dist/token-resolver.js +217 -0
- package/package.json +40 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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 {};
|