@bli-cockpit/mcp 0.1.82 → 0.1.83

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,
@@ -503,16 +509,37 @@ the account.
503
509
 
504
510
  ## Install
505
511
 
512
+ **Current path (every tool except the three legacy ones).** Install the CLI and
513
+ let it register the server:
514
+
515
+ ```bash
516
+ npm i -g @bli-cockpit/cli && cockpit doctor
517
+ ```
518
+
519
+ `cockpit doctor` signs the machine in (device token in
520
+ `~/.config/bli-cockpit/session.json`) and registers `bli-tower` in
521
+ `~/.claude.json` and `~/.codex/config.toml`. See "Registration on an intern
522
+ machine" above, and the
523
+ [connect page](../../docs/onboarding/connect-an-agent-to-tower.md) for manual
524
+ registration in Claude Desktop or any other stdio host, verification and
525
+ troubleshooting.
526
+
527
+ **Building from this checkout** (development only):
528
+
506
529
  ```bash
507
530
  npm install # from repo root — registers the workspace
508
531
  npm run build --workspace=@bli-cockpit/mcp
509
532
  ```
510
533
 
511
- The `bin` entry exposes a `bli-cockpit-mcp` CLI that speaks MCP over stdio.
534
+ 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.
535
+
536
+ ### Legacy event-stream install (frozen)
512
537
 
513
- ### Claude Code (`~/.claude/settings.json`)
538
+ Only needed for `emit_event`, `get_ticket_timeline` and `get_active_tickets`.
539
+ Do not follow this to connect an agent to Tower; the section above is the way.
514
540
 
515
541
  ```jsonc
542
+ // ~/.claude/settings.json — LEGACY
516
543
  {
517
544
  "mcpServers": {
518
545
  "bli-cockpit": {
@@ -528,22 +555,12 @@ The `bin` entry exposes a `bli-cockpit-mcp` CLI that speaks MCP over stdio.
528
555
  }
529
556
  ```
530
557
 
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`
558
+ Run `bli-event login` once on the host; the legacy tools auto-resolve the
559
+ access_token from `~/.config/bli-event/session.json` and refresh it
560
+ transparently. For CI (or any environment without a session file), set
561
+ `BLI_OPERATOR_TOKEN` to a Supabase access_token in the env block; it is used
562
+ verbatim without refresh. For deployed cockpits, set `BLI_API_BASE_URL` to the
563
+ production URL. Never commit tokens.
547
564
 
548
565
  ## Example invocations
549
566
 
@@ -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;
@@ -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
+ }
@@ -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
+ }
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.83",
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
  },