@bli-cockpit/mcp 0.1.82 → 0.1.84

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
@@ -1,5 +1,7 @@
1
1
  # @bli-cockpit/mcp — bli-tower
2
2
 
3
+ **Connecting an agent? Start at [`docs/onboarding/connect-an-agent-to-tower.md`](../../docs/onboarding/connect-an-agent-to-tower.md):** supported clients, setup, verify, troubleshooting. This file is the tool reference.
4
+
3
5
  Two, deliberately separate, tool families over one MCP server (bin
4
6
  `bli-cockpit-mcp`, server id `bli-tower`):
5
7
 
@@ -34,9 +36,13 @@ refusal words, so the choice is about where the agent runs and what it costs.
34
36
  CLI, `cockpit <noun> <verb>`. Nothing loads into the context window until a
35
37
  command runs, the human-readable output is the tersest form of every read,
36
38
  and a person can run the exact same line to check it.
37
- - **The agent has no shell** (claude.ai, Claude Desktop chat, IDE chat panes,
38
- any connector-only host): use this server. It is the only door those hosts
39
- have.
39
+ - **The agent has no shell** (Claude Desktop chat, IDE chat panes, any host
40
+ that can start a local stdio server): use this server. It is the only door
41
+ those hosts have. The claude.ai web app, ChatGPT and dot cannot start a local
42
+ program; they use Tower's hosted address, `https://<dashboard>/api/mcp`, with
43
+ "Sign in with Tower" (BLI-12416, pending its first live proof; see the connect
44
+ page's "Remote hosts"). The hosted server is `@bli-cockpit/mcp/remote`, built
45
+ from the same `createServer`, minus the five laptop-only tools.
40
46
 
41
47
  Measured on 2026-09-26 against production, same account, same moment. Bytes
42
48
  the model actually reads; tokens are an estimate at about 4 bytes a token.
@@ -59,7 +65,7 @@ unless a script needs the fields.
59
65
 
60
66
  **Which half a host shows the model.** A tool result has a text half
61
67
  (`content`) and a structured half (`structuredContent`), and hosts disagree:
62
- Claude Code, claude.ai and Claude Desktop give the model the text half; Codex
68
+ Claude Code and Claude Desktop give the model the text half; Codex
63
69
  gives it the structured half and drops the text. Every read on this server is
64
70
  written so EACH half stands alone. That was not always true: until BLI-10432,
65
71
  `docs_list`, `msg_channels`, `msg_read`, every `careers_*` tool,
@@ -103,7 +109,7 @@ about what an agent can reach.
103
109
 
104
110
  <!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->
105
111
 
106
- **123 of 128 Tower verbs have an MCP twin.**
112
+ **124 of 129 Tower verbs have an MCP twin.**
107
113
  Each tool goes through the SAME door its CLI verb calls, with the same
108
114
  collector device token — never a second route and never a service-role
109
115
  reader. `src/verb-census.test.ts` fails when a verb is in none of the
@@ -166,6 +172,7 @@ three tables below.
166
172
  | `cockpit mail accounts` | `mail_accounts` | `GET /api/mail/accounts` |
167
173
  | `cockpit mail attachment` | `mail_attachment` | `GET /api/mail/attachments/[id]?meta=1` |
168
174
  | `cockpit mail detach` | `mail_detach` | `DELETE /api/mail/accounts` |
175
+ | `cockpit mail draft` | `mail_draft` | `POST /api/mail/drafts` |
169
176
  | `cockpit mail inbox` | `mail_inbox` | `GET /api/mail/inbox` |
170
177
  | `cockpit mail read` | `mail_read` | `GET /api/mail/threads/[id]` |
171
178
  | `cockpit mail search` | `mail_search` | `GET /api/mail/search` |
@@ -503,16 +510,37 @@ the account.
503
510
 
504
511
  ## Install
505
512
 
513
+ **Current path (every tool except the three legacy ones).** Install the CLI and
514
+ let it register the server:
515
+
516
+ ```bash
517
+ npm i -g @bli-cockpit/cli && cockpit doctor
518
+ ```
519
+
520
+ `cockpit doctor` signs the machine in (device token in
521
+ `~/.config/bli-cockpit/session.json`) and registers `bli-tower` in
522
+ `~/.claude.json` and `~/.codex/config.toml`. See "Registration on an intern
523
+ machine" above, and the
524
+ [connect page](../../docs/onboarding/connect-an-agent-to-tower.md) for manual
525
+ registration in Claude Desktop or any other stdio host, verification and
526
+ troubleshooting.
527
+
528
+ **Building from this checkout** (development only):
529
+
506
530
  ```bash
507
531
  npm install # from repo root — registers the workspace
508
532
  npm run build --workspace=@bli-cockpit/mcp
509
533
  ```
510
534
 
511
- The `bin` entry exposes a `bli-cockpit-mcp` CLI that speaks MCP over stdio.
535
+ The `bin` entry exposes a `bli-cockpit-mcp` program that speaks MCP over stdio. The `./remote` export (`handleRemoteMcpRequest`) is the stateless Streamable HTTP handler the dashboard's `/api/mcp` route calls.
536
+
537
+ ### Legacy event-stream install (frozen)
512
538
 
513
- ### Claude Code (`~/.claude/settings.json`)
539
+ Only needed for `emit_event`, `get_ticket_timeline` and `get_active_tickets`.
540
+ Do not follow this to connect an agent to Tower; the section above is the way.
514
541
 
515
542
  ```jsonc
543
+ // ~/.claude/settings.json — LEGACY
516
544
  {
517
545
  "mcpServers": {
518
546
  "bli-cockpit": {
@@ -528,22 +556,12 @@ The `bin` entry exposes a `bli-cockpit-mcp` CLI that speaks MCP over stdio.
528
556
  }
529
557
  ```
530
558
 
531
- Run `bli-event login` once on the host; the MCP auto-resolves the
532
- access_token from `~/.config/bli-event/session.json` and refreshes it
533
- transparently.
534
-
535
- For CI (or any environment without a session file), set
536
- `BLI_OPERATOR_TOKEN` to a Supabase access_token in the env block — it
537
- will be used verbatim without refresh.
538
-
539
- For deployed cockpits, set `BLI_API_BASE_URL` to the production URL.
540
- Never commit tokens — keep them in local settings only.
541
-
542
- ### Claude Desktop
543
-
544
- Same shape, but the file lives at:
545
- - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
546
- - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
559
+ Run `bli-event login` once on the host; the legacy tools auto-resolve the
560
+ access_token from `~/.config/bli-event/session.json` and refresh it
561
+ transparently. For CI (or any environment without a session file), set
562
+ `BLI_OPERATOR_TOKEN` to a Supabase access_token in the env block; it is used
563
+ verbatim without refresh. For deployed cockpits, set `BLI_API_BASE_URL` to the
564
+ production URL. Never commit tokens.
547
565
 
548
566
  ## Example invocations
549
567
 
@@ -33,6 +33,9 @@ export type LoadAgentDoorSessionResult = {
33
33
  message: string;
34
34
  };
35
35
  export declare function agentDoorSessionFilePath(homeDir?: string): string;
36
+ export declare function runWithAgentDoorSession<T>(session: AgentDoorSession, run: () => T): T;
37
+ /** True inside a hosted request: nothing on this host's disk is the caller's. */
38
+ export declare function inRemoteAgentDoorScope(): boolean;
36
39
  export declare function loadAgentDoorSession(options?: {
37
40
  env?: NodeJS.ProcessEnv;
38
41
  homeDir?: string;
@@ -18,6 +18,7 @@
18
18
  *
19
19
  * The token is never logged, printed, or included in an error.
20
20
  */
21
+ import { AsyncLocalStorage } from "node:async_hooks";
21
22
  import fs from "node:fs";
22
23
  import os from "node:os";
23
24
  import path from "node:path";
@@ -26,7 +27,26 @@ export const DEFAULT_DASHBOARD_URL = "https://bli-cockpit-dashboard.vercel.app";
26
27
  export function agentDoorSessionFilePath(homeDir = os.homedir()) {
27
28
  return path.join(homeDir, ...SESSION_RELATIVE_PATH);
28
29
  }
30
+ /**
31
+ * BLI-12416: the hosted server (`remote.ts`) answers many people from one
32
+ * process, so a credential from env or `session.json` would be one person's
33
+ * for everybody. A request-scoped session wins over both: every tool path
34
+ * that calls `loadAgentDoorSession` then works remotely without threading a
35
+ * session through ~100 handlers. Two concurrent scopes never see each other's
36
+ * value (`AsyncLocalStorage`), and nothing here ever logs the token.
37
+ */
38
+ const requestSession = new AsyncLocalStorage();
39
+ export function runWithAgentDoorSession(session, run) {
40
+ return requestSession.run(session, run);
41
+ }
42
+ /** True inside a hosted request: nothing on this host's disk is the caller's. */
43
+ export function inRemoteAgentDoorScope() {
44
+ return requestSession.getStore() !== undefined;
45
+ }
29
46
  export function loadAgentDoorSession(options = {}) {
47
+ const scoped = requestSession.getStore();
48
+ if (scoped)
49
+ return { ok: true, session: scoped };
30
50
  const env = options.env ?? process.env;
31
51
  const envToken = (env.BLI_COCKPIT_MCP_DEVICE_TOKEN ?? "").trim();
32
52
  if (envToken.length > 0) {
@@ -9,7 +9,7 @@
9
9
  * because both talk to the identical on-disk cache directory on the same
10
10
  * machine, and neither depends on the other package to say so.
11
11
  */
12
- import type { AgentDoorSession } from "./agent-door-session.js";
12
+ import { type AgentDoorSession } from "./agent-door-session.js";
13
13
  export type FetchImpl = typeof fetch;
14
14
  export interface AgentDoorResponse {
15
15
  ok: boolean;
@@ -12,6 +12,7 @@
12
12
  import os from "node:os";
13
13
  import { getUserLocalCockpitPaths } from "@bli-cockpit/telemetry-core";
14
14
  import { invalidateReadCacheSurface, readCacheSurfaceForPath, } from "@bli-cockpit/telemetry-core/node-read-cache";
15
+ import { inRemoteAgentDoorScope } from "./agent-door-session.js";
15
16
  /**
16
17
  * How long a door gets before this server gives up on it (BLI-10432).
17
18
  *
@@ -76,7 +77,8 @@ export async function callAgentDoor(session, fetchImpl, method, path, body, time
76
77
  transportError: `Tower answered ${response.status} with a body this server could not read as JSON.`,
77
78
  };
78
79
  }
79
- if (method !== "GET" && response.ok) {
80
+ // Hosted requests have no per-person on-disk read cache to invalidate.
81
+ if (method !== "GET" && response.ok && !inRemoteAgentDoorScope()) {
80
82
  const surface = readCacheSurfaceForPath(path);
81
83
  if (surface) {
82
84
  const stateDir = getUserLocalCockpitPaths(os.homedir()).state_dir;
@@ -6,7 +6,9 @@
6
6
  * The point, in one sentence: an agent working on somebody's behalf should be
7
7
  * able to READ the mail that explains a ticket — the invoice, the vendor's
8
8
  * reply, the bank notice — and send a reply, without a browser and without a
9
- * second copy of anybody's credentials.
9
+ * second copy of anybody's credentials. Or, when the person should be the one
10
+ * to press send, save the reply as a DRAFT in their own mailbox (`mail_draft`,
11
+ * BLI-12530): it reaches nobody, so unlike `mail_send` it needs no `confirm`.
10
12
  *
11
13
  * TWO DELIBERATE ASYMMETRIES with the CLI, both recorded in `verb-census.ts`:
12
14
  *
@@ -6,7 +6,9 @@
6
6
  * The point, in one sentence: an agent working on somebody's behalf should be
7
7
  * able to READ the mail that explains a ticket — the invoice, the vendor's
8
8
  * reply, the bank notice — and send a reply, without a browser and without a
9
- * second copy of anybody's credentials.
9
+ * second copy of anybody's credentials. Or, when the person should be the one
10
+ * to press send, save the reply as a DRAFT in their own mailbox (`mail_draft`,
11
+ * BLI-12530): it reaches nobody, so unlike `mail_send` it needs no `confirm`.
10
12
  *
11
13
  * TWO DELIBERATE ASYMMETRIES with the CLI, both recorded in `verb-census.ts`:
12
14
  *
@@ -32,6 +34,23 @@ function messageLine(message) {
32
34
  return `${message.is_unread ? "*" : " "} ${message.internal_date.slice(0, 16)} ${from} ${message.subject ?? "(no subject)"} [thread ${message.thread_id ?? "?"}]`;
33
35
  }
34
36
  const ADDRESS = EMAIL_INPUT.describe("An email address.");
37
+ /**
38
+ * The JSON `mail_send` and `mail_draft` post. One shape, because the two doors
39
+ * take the same fields: a message that can be sent can be saved unsent, and a
40
+ * field added to one cannot be forgotten by the other.
41
+ */
42
+ function composedBody(args) {
43
+ return {
44
+ account_id: args.account_id,
45
+ to: args.to.map((address) => ({ address })),
46
+ ...(Array.isArray(args.cc) && args.cc.length > 0
47
+ ? { cc: args.cc.map((address) => ({ address })) }
48
+ : {}),
49
+ subject: args.subject ?? "",
50
+ text: args.text,
51
+ ...(args.in_reply_to_message_id ? { in_reply_to_message_id: args.in_reply_to_message_id } : {}),
52
+ };
53
+ }
35
54
  export function registerMailTools(server, deps) {
36
55
  const register = registrarFor(server);
37
56
  register("mail_accounts", {
@@ -186,21 +205,42 @@ export function registerMailTools(server, deps) {
186
205
  const refusal = unconfirmed(args, "Sending mail reaches real people and cannot be recalled.");
187
206
  if (refusal)
188
207
  return refusal;
189
- const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/mail/send", {
190
- account_id: args.account_id,
191
- to: args.to.map((address) => ({ address })),
192
- ...(Array.isArray(args.cc) && args.cc.length > 0
193
- ? { cc: args.cc.map((address) => ({ address })) }
194
- : {}),
195
- subject: args.subject ?? "",
196
- text: args.text,
197
- ...(args.in_reply_to_message_id ? { in_reply_to_message_id: args.in_reply_to_message_id } : {}),
198
- });
208
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/mail/send", composedBody(args));
199
209
  if (!response.ok)
200
210
  return errorResult(doorFailureText("mail_send", response));
201
211
  const sent = response.body.sent;
202
212
  return textResult(`Sent from ${sent?.address ?? "that mailbox"} to ${args.to.join(", ")}${sent?.threaded ? " (as a reply)" : ""}.`, { sent });
203
213
  }));
214
+ register("mail_draft", {
215
+ title: "Save a draft in one mailbox",
216
+ description: "Saves a message as a DRAFT in one of YOUR mailboxes, for a person to read, edit and send themselves. Use it "
217
+ + "when you are preparing mail on somebody's behalf and they should be the one to press send. NOTHING IS SENT and "
218
+ + "nobody is notified, so unlike mail_send there is no confirm flag. `account_id` is REQUIRED and is the mailbox the "
219
+ + "draft is saved in (mail_accounts) — with several attached, that is never something to guess. "
220
+ + "`in_reply_to_message_id` (a message id from mail_read) threads the draft into that conversation the way mail_send "
221
+ + "threads a reply; Gmail files it there only when the subject matches the original's, and the answer says whether it "
222
+ + "did (`threaded`). Only the mailbox's OWNER can draft into it, and only Gmail mailboxes can take one: an IMAP "
223
+ + "mailbox is refused as imap_drafts_unsupported. Answers the provider draft id, the thread id and the account id.",
224
+ inputSchema: {
225
+ account_id: z.string().uuid().describe("The mailbox the draft is saved in (mail_accounts). Never guessed."),
226
+ to: z.array(ADDRESS).min(1).max(50).describe("Recipient email addresses, so the draft is ready to send as it is."),
227
+ cc: z.array(ADDRESS).max(50).optional().describe("Copied email addresses."),
228
+ subject: z.string().max(998).describe("The subject line. For a reply, keep the original's subject or Gmail will not thread it."),
229
+ text: z.string().min(1).max(500_000).describe("The plain-text body."),
230
+ in_reply_to_message_id: z.string().uuid().optional().describe("A message id from mail_read: save this as a reply in that thread."),
231
+ },
232
+ }, async (args) => withSession(deps, async (session) => {
233
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/mail/drafts", composedBody(args));
234
+ if (!response.ok)
235
+ return errorResult(doorFailureText("mail_draft", response));
236
+ const draft = response.body.draft;
237
+ // Both halves carry the ids (BLI-10432): a client that hands the model only
238
+ // `content`, or only `structuredContent`, must still learn where the draft is.
239
+ return textResult(`Saved a draft in ${draft?.address ?? "that mailbox"} for ${args.to.join(", ")}`
240
+ + `${draft?.threaded ? " (in the reply's thread)" : args.in_reply_to_message_id ? " (Gmail did NOT file it in that thread: its subject has to match the original's)" : ""}. `
241
+ + "Nothing was sent: it waits in Drafts for a person to read, edit and send. "
242
+ + `draft_id=${draft?.provider_draft_id ?? "(none returned)"} thread_id=${draft?.provider_thread_id ?? "(none)"} account_id=${draft?.account_id ?? String(args.account_id)}`, { draft });
243
+ }));
204
244
  register("mail_attachment", {
205
245
  title: "Describe an attachment",
206
246
  description: "The filename, mime type and size of one attachment. Attachments are POINTERS in Tower — nothing is stored — and "
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Tower's tools at an HTTPS address (BLI-12416).
3
+ *
4
+ * `handleRemoteMcpRequest` answers ONE Streamable HTTP request: a new server
5
+ * and a stateless web-standard transport per request (no session id, plain
6
+ * JSON replies), built from the SAME `createServer` the stdio bin uses so the
7
+ * tool list cannot fork. The caller's bearer rides an `AsyncLocalStorage`
8
+ * scope, so every tool calls Tower's own `/api/*` doors with THEIR token and
9
+ * the doors enforce who they are. Nothing here widens a read.
10
+ *
11
+ * Left out of the hosted list, with reasons, in `REMOTE_EXCLUDED_TOOLS`.
12
+ * Nothing is logged but tool name, outcome, milliseconds, bytes and a label;
13
+ * never a token, an argument or an answer.
14
+ */
15
+ /**
16
+ * ChatGPT only guarantees the first 512 characters of server instructions are
17
+ * read, so this is the whole briefing: what Tower is, where to start, how
18
+ * writes behave.
19
+ */
20
+ export declare const REMOTE_INSTRUCTIONS: string;
21
+ export interface RemoteMcpSession {
22
+ /** The origin the tools call back, e.g. `https://tower.example`. */
23
+ dashboardUrl: string;
24
+ /** The caller's own bearer (device token or OAuth access token). */
25
+ bearer: string;
26
+ /** A short label for the log line, e.g. a token hash prefix. Never the token. */
27
+ label: string;
28
+ }
29
+ export interface RemoteMcpDeps {
30
+ fetchImpl?: typeof fetch;
31
+ log?: (line: string, fields: Record<string, unknown>) => void;
32
+ }
33
+ export declare function handleRemoteMcpRequest(request: Request, session: RemoteMcpSession, deps?: RemoteMcpDeps): Promise<Response>;
package/dist/remote.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Tower's tools at an HTTPS address (BLI-12416).
3
+ *
4
+ * `handleRemoteMcpRequest` answers ONE Streamable HTTP request: a new server
5
+ * and a stateless web-standard transport per request (no session id, plain
6
+ * JSON replies), built from the SAME `createServer` the stdio bin uses so the
7
+ * tool list cannot fork. The caller's bearer rides an `AsyncLocalStorage`
8
+ * scope, so every tool calls Tower's own `/api/*` doors with THEIR token and
9
+ * the doors enforce who they are. Nothing here widens a read.
10
+ *
11
+ * Left out of the hosted list, with reasons, in `REMOTE_EXCLUDED_TOOLS`.
12
+ * Nothing is logged but tool name, outcome, milliseconds, bytes and a label;
13
+ * never a token, an argument or an answer.
14
+ */
15
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
16
+ import { runWithAgentDoorSession } from "./agent-door-session.js";
17
+ import { createServer } from "./server.js";
18
+ /**
19
+ * ChatGPT only guarantees the first 512 characters of server instructions are
20
+ * read, so this is the whole briefing: what Tower is, where to start, how
21
+ * writes behave.
22
+ */
23
+ export const REMOTE_INSTRUCTIONS = "Tower is Build Launch Iterate's workspace: documents, messages, issues, mail, calendar, meeting notes and the daily brief. "
24
+ + "Start with tower_search to find anything, then use the matching docs_*, msg_*, work_*, mail_*, cal_* or notes_* tool. "
25
+ + "You act as the signed-in person and see only what they can see. Tools that change data are marked as writes, so the app asks the person to confirm first.";
26
+ const TAG = "[mcp-remote]";
27
+ function defaultLog(line, fields) {
28
+ console.info(`${TAG} ${line}`, JSON.stringify(fields));
29
+ }
30
+ export async function handleRemoteMcpRequest(request, session, deps = {}) {
31
+ const log = deps.log ?? defaultLog;
32
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
33
+ const started = Date.now();
34
+ const server = createServer({
35
+ // The three legacy event tools are excluded, so this is never called.
36
+ resolveToken: async () => {
37
+ throw new Error("The legacy event stream is not served remotely.");
38
+ },
39
+ baseUrl: session.dashboardUrl,
40
+ fetchImpl,
41
+ }, {
42
+ remote: {
43
+ instructions: REMOTE_INSTRUCTIONS,
44
+ onToolCall: (call) => log("tool call", { ...call, label: session.label }),
45
+ },
46
+ });
47
+ const transport = new WebStandardStreamableHTTPServerTransport({
48
+ sessionIdGenerator: undefined,
49
+ enableJsonResponse: true,
50
+ });
51
+ return runWithAgentDoorSession({ dashboardUrl: session.dashboardUrl, deviceToken: session.bearer }, async () => {
52
+ try {
53
+ await server.connect(transport);
54
+ const response = await transport.handleRequest(request);
55
+ log("request handled", { method: request.method, status: response.status, ms: Date.now() - started, label: session.label });
56
+ return response;
57
+ }
58
+ catch (error) {
59
+ log("request failed", {
60
+ method: request.method,
61
+ reason: error instanceof Error ? error.name : "unknown",
62
+ ms: Date.now() - started,
63
+ label: session.label,
64
+ });
65
+ throw error;
66
+ }
67
+ });
68
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Tower's mark as MCP `serverInfo.icons` (spec 2025-11-25, SEP-973), so a host
3
+ * that renders server icons shows Tower's own logo with nothing to upload
4
+ * (BLI-12426). Data URIs, not URLs: the icon travels inside the `initialize`
5
+ * answer and works on every deployment address and on a laptop's stdio run.
6
+ *
7
+ * Source: `docs/design/brand/tower-icon.svg` (the PNG is its 128 px render).
8
+ * `server-icons.test.ts` fails when the SVG here stops matching that file.
9
+ * Many hosts do not read this field yet; ChatGPT's create dialog also takes an
10
+ * upload (`docs/design/brand/tower-icon-256.png`).
11
+ */
12
+ export declare const TOWER_SERVER_ICONS: {
13
+ src: string;
14
+ mimeType: string;
15
+ sizes: string[];
16
+ }[];
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Tower's mark as MCP `serverInfo.icons` (spec 2025-11-25, SEP-973), so a host
3
+ * that renders server icons shows Tower's own logo with nothing to upload
4
+ * (BLI-12426). Data URIs, not URLs: the icon travels inside the `initialize`
5
+ * answer and works on every deployment address and on a laptop's stdio run.
6
+ *
7
+ * Source: `docs/design/brand/tower-icon.svg` (the PNG is its 128 px render).
8
+ * `server-icons.test.ts` fails when the SVG here stops matching that file.
9
+ * Many hosts do not read this field yet; ChatGPT's create dialog also takes an
10
+ * upload (`docs/design/brand/tower-icon-256.png`).
11
+ */
12
+ const TOWER_ICON_PNG_128 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAA2FBMVEUAAADy8e3s6+f19PDy7+rx4dXz5tzw1cTqdDTpaSTpZyHtoHXw0r7pZB3oVwjoWQzoVQfslmbdXRjRUg7UVRDmZR+QgXcrHRYvIRktHxhENi7cy8Dz6eCFhYQUFhcYGhsvMDHd3NkwMjLa2tb6+fWHiIb49/O+vbtPUFAjJSV6e3qRkY/m5eEsLi4VFxiUlJIfICFERkbY19RnaGcnKSm0tLGnp6VRU1I8Pj6Oj413d3bKysdbXFzh4N2bm5mAgX/S0s/NzMlqa2qwsK4uLzDX1tOsrKnFxcKJMVDUAAAAAXRSTlMAQObYZgAAA6NJREFUeNrtm2dD2zAQhiMrBdoyunfwyiAmdqBNgEAIo7T9//+osS0neMR3GrY79H4DbN3j92SdLKRWq1hkKYMoVQut8GpK1UYnFM0QBlcdPYEwYAZiVBScIdByBOZUpaIlBDWED2WQ5h6/zISagm8kqO3xmZqOnyWoO3ouC/UDpAkaiB+q6fiyAO0nK20JNbBKguAbsL3zlOnZ87ZQCwYDEKz7u3v7B7FevBQDYBaIDgG7eyz+wb4gAOsFgveqAIgsEB4DVTjwZwCISgkAaR5A+M7GAegaQHQckHRge+fV60hv3gqOhJIA7a137z8s9fHT5y/irYiOw6Fo59AMZdkS0ymZUpwAOK5EIxqgBIBC6vYYgN0HrxUAGHQg9Y6i+ObQAy89HnEDLP0FZTLBV/quCICpTo4G+N8BAksAYOw71omS8MuGTm1uADJy3a/fAgXxJ1PbtQ1+gNCFMwUWBOejshjltcC9UODAjIgDkEv5HPhXEgB0OpTOwJxIAJDr07iZfAUoLgxBvlIsqAwAuYlt9LI1cJ6td55lputibx7d6txKOUAHk7AV666fmQUsupmCPwq9Ss0MvMiSe0MKgIzOo244XvsYD5E5Y6NkPR5x7egNOjkDJqzglGyW7UlIAPo96r8X0HQNBLjyw3Ye7rgdiN7g4BJqH56UzjM5QALY0fsznEKfDCAAXWRygANgGTi9lnfg1knnAOlAPIZ2wOZhAOM+nYMygHXZj9+ByQD8aIMB4pK4zgHKgTgDQCFEArCS+JCMRcUDUT8zEHkBXAixACydq8EXMxR7DqIQYgFYSQwwxSj1E1QI0Q4kJZFbC8S6Aerr+EZsWgIVQjxAXBK5pyJQIcQDsJLIKbAQ4gHILDgs7nRr5S8ACyEHwI9Z7ovfS88Wj3q5K36i1r+QSzS5FY++7aSev9fNXYJbOhNeI3LTAB3RlToNoAE0gAbQABpAA2gADaAB/mIAq2mAxh3QABpAA2gADaAB/h0AsTtpFqBfjwMjl8n+5acAPHv1F769hXx7Sumx7zCl4pvmMPl9yW6NogZ5AeCdLSX7VTY5wNEJMFtr+PbWce5tVw0Q727nsEC5A7y766sB4OiGmD1+Ph4gOWLBYQG8y7Ezhv9bmonPlwRY3BkQP+Qhp8cHjZqInz7sVf9Rs9URm+Y8aPC4YVH8mrNQeOayxvgbTp02+viMoJ40tDar4nPP0eMDh68rdYESKDzrCpUxwNEr7Y5FoX4DijSqxkMGuGsAAAAASUVORK5CYII=";
13
+ const TOWER_ICON_SVG = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTQiIGZpbGw9IiNGMkYxRUQiLz4KICA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg0IDQpIHNjYWxlKC44NzUpIj4KICA8cGF0aCBmaWxsPSIjMTgxQTFCIiBkPSJNMzAgOGg0djZoMjB2NEgxMHYtNGgyMHpNMTIgMThoNDBsLTUgMTRIMTd6TTI4IDMyaDh2MjRoNnY0SDIydi00aDZ6Ii8+CiAgPHBhdGggZmlsbD0iI0YyRjFFRCIgZD0iTTE3IDIyaDd2NmgtNXpNMjcgMjJoMTB2NkgyN3pNNDAgMjJoN2wtMiA2aC01eiIvPgogIDxwYXRoIGZpbGw9IiNFODU5MEMiIGQ9Ik0yOSA0aDZ2NmgtNnoiLz4KICA8L2c+Cjwvc3ZnPgo=";
14
+ export const TOWER_SERVER_ICONS = [
15
+ { src: TOWER_ICON_PNG_128, mimeType: "image/png", sizes: ["128x128"] },
16
+ { src: TOWER_ICON_SVG, mimeType: "image/svg+xml", sizes: ["any"] },
17
+ ];
package/dist/server.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { z } from "zod";
9
+ import { type CreateServerOptions } from "./tool-registration.js";
9
10
  import type { TokenResolver } from "./token-resolver.js";
10
11
  export type FetchImpl = typeof fetch;
11
12
  export interface ServerDeps {
@@ -99,4 +100,5 @@ export declare function getActiveTickets(deps: ServerDeps, args: {
99
100
  }): Promise<{
100
101
  tickets: unknown[];
101
102
  }>;
102
- export declare function createServer(deps: ServerDeps): McpServer;
103
+ export type { CreateServerOptions } from "./tool-registration.js";
104
+ export declare function createServer(deps: ServerDeps, options?: CreateServerOptions): McpServer;
package/dist/server.js CHANGED
@@ -35,6 +35,8 @@ import { registerCareersTools } from "./careers-tools.js";
35
35
  import { registerRenameTools } from "./rename-tools.js";
36
36
  import { registerUsageTools } from "./usage-tools.js";
37
37
  import { registerSourcesTools } from "./sources-tools.js";
38
+ import { TOWER_SERVER_ICONS } from "./server-icons.js";
39
+ import { installToolRegistration } from "./tool-registration.js";
38
40
  export const PACKAGE_NAME = "@bli-cockpit/mcp";
39
41
  /**
40
42
  * The version this server tells a client in `initialize`, read from the
@@ -368,12 +370,11 @@ function errorContent(err) {
368
370
  ],
369
371
  };
370
372
  }
371
- // ---- server factory ---------------------------------------------------------
372
- export function createServer(deps) {
373
- const server = new McpServer({
374
- name: PACKAGE_NAME,
375
- version: PACKAGE_VERSION,
376
- });
373
+ export function createServer(deps, options = {}) {
374
+ const server = new McpServer({ name: PACKAGE_NAME, version: PACKAGE_VERSION, icons: TOWER_SERVER_ICONS }, options.remote ? { instructions: options.remote.instructions } : undefined);
375
+ const remote = options.remote !== undefined;
376
+ // BLI-12416: annotations from the catalog, hosted exclusions and call logging.
377
+ installToolRegistration(server, options);
377
378
  server.registerTool("emit_event", {
378
379
  title: "Emit a cockpit event",
379
380
  description: "Append a canonical event to the BLI Cockpit event stream for a given ticket. Wraps POST /api/events/emit. Supports idempotency_key for at-most-once writes.",
@@ -447,7 +448,11 @@ export function createServer(deps) {
447
448
  // BLI-3732: `jarvis_*` — the assistant itself, on the same device-token path
448
449
  // as the three families above, over the doors `cockpit jarvis` already
449
450
  // calls. It is the last Tower surface that had a CLI door and no MCP one.
450
- registerJarvisTools(server, { fetchImpl: deps.fetchImpl });
451
+ registerJarvisTools(server, {
452
+ fetchImpl: deps.fetchImpl,
453
+ // Hosted: "last turn" is never read from the host's own disk.
454
+ ...(remote ? { readBookmark: async () => null } : {}),
455
+ });
451
456
  // BLI-3912: `models_*` — the model-card shelf, over the same door
452
457
  // `cockpit models` calls. Read-only: a card is changed by re-reading the
453
458
  // provider, never by an agent editing a number.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * MCP tool annotations and the hosted server's exclusions (BLI-12416).
3
+ *
4
+ * ChatGPT treats a tool WITHOUT `readOnlyHint: true` as a write and asks the
5
+ * person to confirm every call, so ~109 unannotated tools would nag on every
6
+ * read. `tool-catalog.ts` already sorts every tool into read, dry-run and
7
+ * write; the hints derive from it here and nowhere else, and
8
+ * `tool-annotations.test.ts` fails when a registered tool has none or
9
+ * disagrees with the catalog.
10
+ */
11
+ export interface TowerToolAnnotations {
12
+ readOnlyHint: boolean;
13
+ destructiveHint?: boolean;
14
+ [key: string]: unknown;
15
+ }
16
+ /**
17
+ * Writes a person cannot take back by writing again: deletes, revokes,
18
+ * detaches and a merge. These also carry `destructiveHint: true`.
19
+ */
20
+ export declare const DESTRUCTIVE_TOOLS: readonly ["docs_delete", "cal_delete", "cal_detach", "mail_detach", "notes_detach", "notes_rmdir", "settings_delete", "team_device_revoke", "github_merge"];
21
+ /**
22
+ * Tools the HOSTED server leaves out, each with the reason. A hosted process
23
+ * is one machine answering many people: a tool that reads that machine's
24
+ * files or spawns a program there would act on the host, not on the caller's
25
+ * computer, so it is absent rather than quietly wrong.
26
+ */
27
+ export declare const REMOTE_EXCLUDED_TOOLS: Readonly<Record<string, string>>;
28
+ /** `null` when the tool is in no catalog list (the test then fails, loudly). */
29
+ export declare function annotationsFor(name: string, catalog: ReadonlySet<string>): TowerToolAnnotations | null;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * MCP tool annotations and the hosted server's exclusions (BLI-12416).
3
+ *
4
+ * ChatGPT treats a tool WITHOUT `readOnlyHint: true` as a write and asks the
5
+ * person to confirm every call, so ~109 unannotated tools would nag on every
6
+ * read. `tool-catalog.ts` already sorts every tool into read, dry-run and
7
+ * write; the hints derive from it here and nowhere else, and
8
+ * `tool-annotations.test.ts` fails when a registered tool has none or
9
+ * disagrees with the catalog.
10
+ */
11
+ import { READ_TOOLS } from "./tool-catalog.js";
12
+ /**
13
+ * Writes a person cannot take back by writing again: deletes, revokes,
14
+ * detaches and a merge. These also carry `destructiveHint: true`.
15
+ */
16
+ export const DESTRUCTIVE_TOOLS = [
17
+ "docs_delete",
18
+ "cal_delete",
19
+ "cal_detach",
20
+ "mail_detach",
21
+ "notes_detach",
22
+ "notes_rmdir",
23
+ "settings_delete",
24
+ "team_device_revoke",
25
+ "github_merge",
26
+ ];
27
+ /**
28
+ * Tools the HOSTED server leaves out, each with the reason. A hosted process
29
+ * is one machine answering many people: a tool that reads that machine's
30
+ * files or spawns a program there would act on the host, not on the caller's
31
+ * computer, so it is absent rather than quietly wrong.
32
+ */
33
+ export const REMOTE_EXCLUDED_TOOLS = {
34
+ emit_event: "legacy event stream; authenticates with an operator token the host does not hold",
35
+ get_ticket_timeline: "legacy event stream; authenticates with an operator token the host does not hold",
36
+ get_active_tickets: "legacy event stream; authenticates with an operator token the host does not hold",
37
+ notes_upload: "reads a file by path from the machine the server runs on; use notes_paste with the text",
38
+ sources_check: "spawns `cockpit sources check` on the machine the server runs on",
39
+ };
40
+ const READS = new Set(READ_TOOLS);
41
+ const DESTRUCTIVE = new Set(DESTRUCTIVE_TOOLS);
42
+ /** `null` when the tool is in no catalog list (the test then fails, loudly). */
43
+ export function annotationsFor(name, catalog) {
44
+ if (!catalog.has(name))
45
+ return null;
46
+ if (READS.has(name))
47
+ return { readOnlyHint: true };
48
+ return DESTRUCTIVE.has(name)
49
+ ? { readOnlyHint: false, destructiveHint: true }
50
+ : { readOnlyHint: false, destructiveHint: false };
51
+ }
@@ -21,7 +21,7 @@ export declare const DRY_RUN_TOOLS: readonly ["ops_recompile", "memory_requeue"]
21
21
  * candidates, share calendars. Never called by the smoke; covered by the unit
22
22
  * suites (`write-tools.test.ts` and each family's own).
23
23
  */
24
- export declare const WRITE_TOOLS: readonly ["emit_event", "docs_create", "docs_update", "docs_delete", "docs_link", "msg_create_channel", "msg_dm", "msg_send", "work_create_issue", "work_update_issue", "work_move_issue", "work_comment_issue", "careers_rescreen", "careers_send_takehome", "careers_invite", "careers_grade", "careers_decide", "careers_followup", "careers_interview", "careers_takehome_set", "careers_settings_set", "rename_resolve", "github_merge", "github_retitle", "github_rerun", "mail_send", "mail_sync", "mail_detach", "cal_create", "cal_share", "cal_sync", "cal_detach", "cal_delete", "jarvis_dispatch", "notes_mkdir", "notes_rmdir", "notes_rename", "notes_upload", "notes_paste", "notes_share", "notes_unshare", "notes_move", "notes_sync", "notes_detach", "memory_experience", "scout_start", "scout_dismiss", "scout_undo", "brief_rewrite", "brief_correct", "brief_done", "brief_undone", "settings_set", "settings_delete", "model_set", "team_invite", "team_role", "team_device_revoke"];
24
+ export declare const WRITE_TOOLS: readonly ["emit_event", "docs_create", "docs_update", "docs_delete", "docs_link", "msg_create_channel", "msg_dm", "msg_send", "work_create_issue", "work_update_issue", "work_move_issue", "work_comment_issue", "careers_rescreen", "careers_send_takehome", "careers_invite", "careers_grade", "careers_decide", "careers_followup", "careers_interview", "careers_takehome_set", "careers_settings_set", "rename_resolve", "github_merge", "github_retitle", "github_rerun", "mail_send", "mail_draft", "mail_sync", "mail_detach", "cal_create", "cal_share", "cal_sync", "cal_detach", "cal_delete", "jarvis_dispatch", "notes_mkdir", "notes_rmdir", "notes_rename", "notes_upload", "notes_paste", "notes_share", "notes_unshare", "notes_move", "notes_sync", "notes_detach", "memory_experience", "scout_start", "scout_dismiss", "scout_undo", "brief_rewrite", "brief_correct", "brief_done", "brief_undone", "settings_set", "settings_delete", "model_set", "team_invite", "team_role", "team_device_revoke"];
25
25
  /** Reads that spend a model turn. The smoke's `--no-model` leaves these out. */
26
26
  export declare const MODEL_SPENDING_TOOLS: readonly ["jarvis_ask", "jarvis_check"];
27
27
  /**
@@ -116,6 +116,8 @@ export const WRITE_TOOLS = [
116
116
  "github_retitle",
117
117
  "github_rerun",
118
118
  "mail_send",
119
+ // BLI-12530: writes into a mailbox (a draft), reaches nobody. Never smoked.
120
+ "mail_draft",
119
121
  "mail_sync",
120
122
  "mail_detach",
121
123
  "cal_create",
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The one hook every tool registers through (BLI-12416).
3
+ *
4
+ * ~30 modules call `server.registerTool`. Rather than edit each, `createServer`
5
+ * installs this wrapper once, before any of them run, and it does three things
6
+ * to every registration:
7
+ *
8
+ * 1. adds `annotations` derived from `tool-catalog.ts` (`tool-annotations.ts`),
9
+ * so ChatGPT runs reads without a prompt and confirms writes;
10
+ * 2. hosted mode only: removes the tools in `REMOTE_EXCLUDED_TOOLS`, which
11
+ * would otherwise touch the host's own disk or spawn a program there;
12
+ * 3. hosted mode only: reports tool, outcome, milliseconds and response bytes
13
+ * per call (never arguments or answers) to `onToolCall`.
14
+ */
15
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ export interface ToolCallReport {
17
+ tool: string;
18
+ outcome: "ok" | "tool_error" | "threw";
19
+ ms: number;
20
+ bytes: number;
21
+ }
22
+ export interface CreateServerOptions {
23
+ /**
24
+ * The hosted server: leaves out `REMOTE_EXCLUDED_TOOLS`, never reads this
25
+ * machine's turn bookmark, and carries `instructions`.
26
+ */
27
+ remote?: {
28
+ instructions: string;
29
+ onToolCall?: (call: ToolCallReport) => void;
30
+ };
31
+ }
32
+ export declare function installToolRegistration(server: McpServer, options: CreateServerOptions): void;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The one hook every tool registers through (BLI-12416).
3
+ *
4
+ * ~30 modules call `server.registerTool`. Rather than edit each, `createServer`
5
+ * installs this wrapper once, before any of them run, and it does three things
6
+ * to every registration:
7
+ *
8
+ * 1. adds `annotations` derived from `tool-catalog.ts` (`tool-annotations.ts`),
9
+ * so ChatGPT runs reads without a prompt and confirms writes;
10
+ * 2. hosted mode only: removes the tools in `REMOTE_EXCLUDED_TOOLS`, which
11
+ * would otherwise touch the host's own disk or spawn a program there;
12
+ * 3. hosted mode only: reports tool, outcome, milliseconds and response bytes
13
+ * per call (never arguments or answers) to `onToolCall`.
14
+ */
15
+ import { REMOTE_EXCLUDED_TOOLS, annotationsFor } from "./tool-annotations.js";
16
+ import { DRY_RUN_TOOLS, READ_TOOLS, WRITE_TOOLS } from "./tool-catalog.js";
17
+ const CATALOGUED_TOOLS = new Set([...READ_TOOLS, ...DRY_RUN_TOOLS, ...WRITE_TOOLS]);
18
+ export function installToolRegistration(server, options) {
19
+ const remote = options.remote !== undefined;
20
+ const onToolCall = options.remote?.onToolCall;
21
+ const register = server.registerTool.bind(server);
22
+ server.registerTool = (name, config, handler) => {
23
+ const annotations = annotationsFor(name, CATALOGUED_TOOLS);
24
+ const measured = onToolCall
25
+ ? async (...args) => {
26
+ const started = Date.now();
27
+ try {
28
+ const result = await handler(...args);
29
+ onToolCall({
30
+ tool: name,
31
+ outcome: result?.isError ? "tool_error" : "ok",
32
+ ms: Date.now() - started,
33
+ bytes: JSON.stringify(result ?? null).length,
34
+ });
35
+ return result;
36
+ }
37
+ catch (error) {
38
+ onToolCall({ tool: name, outcome: "threw", ms: Date.now() - started, bytes: 0 });
39
+ throw error;
40
+ }
41
+ }
42
+ : handler;
43
+ const handle = register(name, annotations && !config.annotations ? { ...config, annotations } : config, measured);
44
+ if (remote && name in REMOTE_EXCLUDED_TOOLS)
45
+ handle.remove();
46
+ return handle;
47
+ };
48
+ }
@@ -268,6 +268,8 @@ export const MCP_TWINS = {
268
268
  "mail read": { tool: "mail_read", door: "GET /api/mail/threads/[id]" },
269
269
  "mail search": { tool: "mail_search", door: "GET /api/mail/search" },
270
270
  "mail send": { tool: "mail_send", door: "POST /api/mail/send" },
271
+ // BLI-12530: the same message, saved unsent in the owner's own mailbox.
272
+ "mail draft": { tool: "mail_draft", door: "POST /api/mail/drafts" },
271
273
  "mail attachment": { tool: "mail_attachment", door: "GET /api/mail/attachments/[id]?meta=1" },
272
274
  "mail sync": { tool: "mail_sync", door: "POST /api/mail/accounts/[id]/sync" },
273
275
  "mail sync-status": { tool: "mail_sync_status", door: "GET /api/mail/sync-status" },
package/package.json CHANGED
@@ -1,9 +1,16 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.82",
3
+ "version": "0.1.84",
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",
7
+ "exports": {
8
+ "./remote": {
9
+ "types": "./dist/remote.d.ts",
10
+ "default": "./dist/remote.js"
11
+ },
12
+ "./package.json": "./package.json"
13
+ },
7
14
  "bin": {
8
15
  "bli-cockpit-mcp": "./dist/index.js"
9
16
  },