@bli-cockpit/mcp 0.1.81 → 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,
@@ -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
- **119 of 124 Tower verbs have an MCP twin.**
112
+ **123 of 128 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
@@ -150,6 +156,10 @@ three tables below.
150
156
  | `cockpit docs sync-status` | `docs_sync_status` | `GET /api/docs/notion-sync` |
151
157
  | `cockpit docs tree` | `docs_tree` | `GET /api/docs/tree` |
152
158
  | `cockpit docs update` | `docs_update` | `PATCH /api/docs/documents/[id]` |
159
+ | `cockpit github merge` | `github_merge` | `POST /api/github/pull-request (merge)` |
160
+ | `cockpit github pr` | `github_pr` | `POST /api/github/pull-request (read)` |
161
+ | `cockpit github rerun` | `github_rerun` | `POST /api/github/pull-request (rerun)` |
162
+ | `cockpit github retitle` | `github_retitle` | `POST /api/github/pull-request (retitle)` |
153
163
  | `cockpit issue comment` | `work_comment_issue` | `POST /api/work/issues/[id]/comments` |
154
164
  | `cockpit issue create` | `work_create_issue` | `POST /api/work/issues` |
155
165
  | `cockpit issue history` | `work_issue_history` | `GET /api/work/issues/[id]/history` |
@@ -499,16 +509,37 @@ the account.
499
509
 
500
510
  ## Install
501
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
+
502
529
  ```bash
503
530
  npm install # from repo root — registers the workspace
504
531
  npm run build --workspace=@bli-cockpit/mcp
505
532
  ```
506
533
 
507
- 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)
508
537
 
509
- ### 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.
510
540
 
511
541
  ```jsonc
542
+ // ~/.claude/settings.json — LEGACY
512
543
  {
513
544
  "mcpServers": {
514
545
  "bli-cockpit": {
@@ -524,22 +555,12 @@ The `bin` entry exposes a `bli-cockpit-mcp` CLI that speaks MCP over stdio.
524
555
  }
525
556
  ```
526
557
 
527
- Run `bli-event login` once on the host; the MCP auto-resolves the
528
- access_token from `~/.config/bli-event/session.json` and refreshes it
529
- transparently.
530
-
531
- For CI (or any environment without a session file), set
532
- `BLI_OPERATOR_TOKEN` to a Supabase access_token in the env block — it
533
- will be used verbatim without refresh.
534
-
535
- For deployed cockpits, set `BLI_API_BASE_URL` to the production URL.
536
- Never commit tokens — keep them in local settings only.
537
-
538
- ### Claude Desktop
539
-
540
- Same shape, but the file lives at:
541
- - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
542
- - 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.
543
564
 
544
565
  ## Example invocations
545
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,18 @@
1
+ /**
2
+ * `github_pr` / `github_merge` / `github_retitle` / `github_rerun` (BLI-12385):
3
+ * the MCP twins of `cockpit github pr|merge|retitle|rerun`, over the same door
4
+ * (`POST /api/github/pull-request`) with the same device token.
5
+ *
6
+ * The door runs JARVIS's own GitHub implementations, so the lock is the same:
7
+ * the caller's OWN GitHub login (on the roster) must hold write, maintain or
8
+ * admin on the repo, and a change is TWO STEPS. The first call (no `confirm`)
9
+ * changes nothing and returns what would happen plus an eight-character code;
10
+ * the second carries `confirm`. The code is signed to the caller, repo, pull
11
+ * request and exact change. An agent must pass a code only after the PERSON has
12
+ * sent it back: whether to merge is theirs, and the descriptions say so.
13
+ * A refusal keeps the door's reason label and sentence verbatim.
14
+ */
15
+ import { type ToolDeps } from "./tool-result.js";
16
+ export declare function registerGithubTools(server: {
17
+ registerTool: (...args: never[]) => unknown;
18
+ }, deps: ToolDeps): void;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `github_pr` / `github_merge` / `github_retitle` / `github_rerun` (BLI-12385):
3
+ * the MCP twins of `cockpit github pr|merge|retitle|rerun`, over the same door
4
+ * (`POST /api/github/pull-request`) with the same device token.
5
+ *
6
+ * The door runs JARVIS's own GitHub implementations, so the lock is the same:
7
+ * the caller's OWN GitHub login (on the roster) must hold write, maintain or
8
+ * admin on the repo, and a change is TWO STEPS. The first call (no `confirm`)
9
+ * changes nothing and returns what would happen plus an eight-character code;
10
+ * the second carries `confirm`. The code is signed to the caller, repo, pull
11
+ * request and exact change. An agent must pass a code only after the PERSON has
12
+ * sent it back: whether to merge is theirs, and the descriptions say so.
13
+ * A refusal keeps the door's reason label and sentence verbatim.
14
+ */
15
+ import { z } from "zod";
16
+ import { callAgentDoor } from "./agent-door.js";
17
+ import { dataResult, doorFailureText, errorResult, registrarFor, textResult, withSession, } from "./tool-result.js";
18
+ const PATH = "/api/github/pull-request";
19
+ const TWO_STEPS = "TWO STEPS: call it WITHOUT confirm first. That changes nothing and returns what would happen plus an "
20
+ + "eight-character confirmation code; show the person exactly that and ask them to send the code back. Only then "
21
+ + "call again with the SAME arguments plus confirm set to the code THE PERSON gave you. Never invent a code, "
22
+ + "never reuse one the person did not send, and never change the arguments between the two calls (the code is "
23
+ + "signed to the exact change). It runs only if the person's OWN GitHub login has write, maintain or admin on the "
24
+ + "repo; otherwise it refuses with github_login_unknown or github_permission_insufficient: relay that as written. "
25
+ + "Only BLI-INC repositories.";
26
+ const repoField = z.string().min(1).max(100).describe("`owner/repo` or a bare repo name in BLI-INC, e.g. bli-cockpit.");
27
+ const numberField = z.number().int().min(1).describe("The pull request number.");
28
+ const confirmField = z
29
+ .string()
30
+ .min(1)
31
+ .max(64)
32
+ .optional()
33
+ .describe("The code the preview returned, which the PERSON sent back. Omit on the first call.");
34
+ export function registerGithubTools(server, deps) {
35
+ const register = registrarFor(server);
36
+ async function press(tool, session, body) {
37
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", PATH, body);
38
+ if (!response.ok)
39
+ return errorResult(doorFailureText(tool, response));
40
+ const result = (response.body["result"] ?? {});
41
+ if (result["status"] === "needs_confirmation") {
42
+ return textResult(`Nothing has been changed. Would ${String(result["preview"] ?? "make this change")} `
43
+ + `To go ahead, the person must send back the code ${String(result["confirmationCode"] ?? "")}; `
44
+ + `then call ${tool} again with the same arguments and confirm set to that code.`, result);
45
+ }
46
+ if (result["status"] === "done")
47
+ return textResult(String(result["sayThis"] ?? "Done."), result);
48
+ return dataResult("GitHub pull request", result);
49
+ }
50
+ register("github_pr", {
51
+ title: "Read one pull request from GitHub",
52
+ description: "GitHub's own live record of ONE pull request in BLI-INC: merged (and by whom), open/closed, draft, mergeable, "
53
+ + "title and CI. GitHub is the source of truth: when somebody asserts a state, read it here. Pass `claim` "
54
+ + "(merged, open, closed, ci_green or mergeable) to get GitHub's verdict on that assertion with the correction "
55
+ + "sentence. `cockpit github pr`, same door.",
56
+ inputSchema: {
57
+ repo: repoField,
58
+ pull_number: numberField,
59
+ claim: z.enum(["merged", "open", "closed", "ci_green", "mergeable"]).optional().describe("The state somebody asserted."),
60
+ },
61
+ }, async (args) => withSession(deps, (session) => press("github_pr", session, {
62
+ action: "read",
63
+ repo: String(args.repo),
64
+ pullNumber: args.pull_number,
65
+ ...(args.claim ? { claim: args.claim } : {}),
66
+ })));
67
+ register("github_merge", {
68
+ title: "Merge a pull request (two steps, caller's own GitHub access)",
69
+ description: `Merge ONE pull request in BLI-INC, squash by default. \`cockpit github merge\`, same door. ${TWO_STEPS}`,
70
+ inputSchema: {
71
+ repo: repoField,
72
+ pull_number: numberField,
73
+ method: z.enum(["squash", "merge", "rebase"]).optional().describe("Defaults to squash."),
74
+ confirm: confirmField,
75
+ },
76
+ }, async (args) => withSession(deps, (session) => press("github_merge", session, {
77
+ action: "merge",
78
+ repo: String(args.repo),
79
+ pullNumber: args.pull_number,
80
+ ...(args.method ? { method: args.method } : {}),
81
+ ...(args.confirm ? { confirm: String(args.confirm) } : {}),
82
+ })));
83
+ register("github_retitle", {
84
+ title: "Retitle a pull request (two steps, caller's own GitHub access)",
85
+ description: `Change the title of ONE pull request in BLI-INC. \`cockpit github retitle\`, same door. ${TWO_STEPS}`,
86
+ inputSchema: {
87
+ repo: repoField,
88
+ pull_number: numberField,
89
+ title: z.string().min(1).max(256).describe("The new title."),
90
+ confirm: confirmField,
91
+ },
92
+ }, async (args) => withSession(deps, (session) => press("github_retitle", session, {
93
+ action: "retitle",
94
+ repo: String(args.repo),
95
+ pullNumber: args.pull_number,
96
+ title: String(args.title),
97
+ ...(args.confirm ? { confirm: String(args.confirm) } : {}),
98
+ })));
99
+ register("github_rerun", {
100
+ title: "Re-run failed workflow jobs (two steps, caller's own GitHub access)",
101
+ description: "Re-run the FAILED jobs of workflow runs in BLI-INC: pass pull_number for every failed workflow on that pull "
102
+ + `request's head commit, or run_id for one run (exactly one). \`cockpit github rerun\`, same door. ${TWO_STEPS}`,
103
+ inputSchema: {
104
+ repo: repoField,
105
+ pull_number: numberField.optional(),
106
+ run_id: z.number().int().min(1).optional().describe("One workflow run, instead of pull_number."),
107
+ confirm: confirmField,
108
+ },
109
+ }, async (args) => withSession(deps, (session) => press("github_rerun", session, {
110
+ action: "rerun",
111
+ repo: String(args.repo),
112
+ ...(args.pull_number ? { pullNumber: args.pull_number } : {}),
113
+ ...(args.run_id ? { runId: args.run_id } : {}),
114
+ ...(args.confirm ? { confirm: String(args.confirm) } : {}),
115
+ })));
116
+ }
@@ -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
@@ -23,6 +23,7 @@ import { registerMemoryExperienceTools } from "./memory-experience-tools.js";
23
23
  import { registerMemoryProfileTools } from "./memory-profile-tools.js";
24
24
  import { registerMemoryRequeueTools } from "./memory-requeue-tools.js";
25
25
  import { registerModelsTools } from "./models-tools.js";
26
+ import { registerGithubTools } from "./github-tools.js";
26
27
  import { registerOpsTools } from "./ops-tools.js";
27
28
  import { registerPagesTools } from "./pages-tools.js";
28
29
  import { registerSearchTool } from "./search-tool.js";
@@ -34,6 +35,8 @@ import { registerCareersTools } from "./careers-tools.js";
34
35
  import { registerRenameTools } from "./rename-tools.js";
35
36
  import { registerUsageTools } from "./usage-tools.js";
36
37
  import { registerSourcesTools } from "./sources-tools.js";
38
+ import { TOWER_SERVER_ICONS } from "./server-icons.js";
39
+ import { installToolRegistration } from "./tool-registration.js";
37
40
  export const PACKAGE_NAME = "@bli-cockpit/mcp";
38
41
  /**
39
42
  * The version this server tells a client in `initialize`, read from the
@@ -367,12 +370,11 @@ function errorContent(err) {
367
370
  ],
368
371
  };
369
372
  }
370
- // ---- server factory ---------------------------------------------------------
371
- export function createServer(deps) {
372
- const server = new McpServer({
373
- name: PACKAGE_NAME,
374
- version: PACKAGE_VERSION,
375
- });
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);
376
378
  server.registerTool("emit_event", {
377
379
  title: "Emit a cockpit event",
378
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.",
@@ -429,6 +431,8 @@ export function createServer(deps) {
429
431
  // BLI-12378: the twins of `cockpit rename list|resolve`, the human's call on
430
432
  // whether a correction becomes a rule.
431
433
  registerRenameTools(server, deps);
434
+ // BLI-12385: GitHub PR read, and merge / retitle / re-run behind the two-step code.
435
+ registerGithubTools(server, deps);
432
436
  registerUsageTools(server, { fetchImpl: deps.fetchImpl });
433
437
  // BLI-3708: the mailboxes a person attached. Reads, a send and a sync over
434
438
  // the same /api/mail/** doors `cockpit mail` calls; `add-imap` deliberately
@@ -444,7 +448,11 @@ export function createServer(deps) {
444
448
  // BLI-3732: `jarvis_*` — the assistant itself, on the same device-token path
445
449
  // as the three families above, over the doors `cockpit jarvis` already
446
450
  // calls. It is the last Tower surface that had a CLI door and no MCP one.
447
- 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
+ });
448
456
  // BLI-3912: `models_*` — the model-card shelf, over the same door
449
457
  // `cockpit models` calls. Read-only: a card is changed by re-reading the
450
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
+ }
@@ -10,7 +10,7 @@
10
10
  * from `src/` under Node's type stripping, so it may import nothing.
11
11
  */
12
12
  /** Tools that change nothing. The smoke calls every one. */
13
- export declare const READ_TOOLS: readonly ["get_ticket_timeline", "get_active_tickets", "docs_list", "docs_tree", "docs_read", "docs_sync_status", "msg_channels", "msg_read", "msg_thread", "work_list_issues", "work_get_issue", "work_issue_history", "work_list_projects", "careers_list", "careers_show", "careers_resume_url", "careers_takehome_show", "careers_settings_show", "rename_pending", "usage_people", "usage_sessions", "mail_accounts", "mail_inbox", "mail_read", "mail_search", "mail_attachment", "mail_sync_status", "cal_today", "cal_week", "cal_next", "cal_find", "cal_calendars", "cal_sync_status", "jarvis_ask", "jarvis_trace", "jarvis_check", "models_list", "models_card", "models_compare", "brief_read", "brief_history", "brief_status", "brief_missed", "notes_folders", "notes_list", "notes_show", "notes_shelf", "notes_shelves", "notes_place", "notes_accounts", "notes_sync_status", "notes_granola_status", "ops_status", "slack_coverage", "slack_read", "memory_profile", "memory_consolidation", "settings_show", "settings_list", "model_show", "team_members", "team_device_list", "scout_board", "workbook_read", "tower_search", "sources_check"];
13
+ export declare const READ_TOOLS: readonly ["get_ticket_timeline", "get_active_tickets", "docs_list", "docs_tree", "docs_read", "docs_sync_status", "msg_channels", "msg_read", "msg_thread", "work_list_issues", "work_get_issue", "work_issue_history", "work_list_projects", "careers_list", "careers_show", "careers_resume_url", "careers_takehome_show", "careers_settings_show", "rename_pending", "github_pr", "usage_people", "usage_sessions", "mail_accounts", "mail_inbox", "mail_read", "mail_search", "mail_attachment", "mail_sync_status", "cal_today", "cal_week", "cal_next", "cal_find", "cal_calendars", "cal_sync_status", "jarvis_ask", "jarvis_trace", "jarvis_check", "models_list", "models_card", "models_compare", "brief_read", "brief_history", "brief_status", "brief_missed", "notes_folders", "notes_list", "notes_show", "notes_shelf", "notes_shelves", "notes_place", "notes_accounts", "notes_sync_status", "notes_granola_status", "ops_status", "slack_coverage", "slack_read", "memory_profile", "memory_consolidation", "settings_show", "settings_list", "model_show", "team_members", "team_device_list", "scout_board", "workbook_read", "tower_search", "sources_check"];
14
14
  /**
15
15
  * Tools that write, but answer a dry run that writes nothing. The smoke calls
16
16
  * each one ONLY with its dry-run flag set (`scripts/smoke.ts` says which).
@@ -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", "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_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
  /**
@@ -30,6 +30,7 @@ export const READ_TOOLS = [
30
30
  "careers_takehome_show",
31
31
  "careers_settings_show",
32
32
  "rename_pending",
33
+ "github_pr",
33
34
  "usage_people",
34
35
  "usage_sessions",
35
36
  "mail_accounts",
@@ -111,6 +112,9 @@ export const WRITE_TOOLS = [
111
112
  "careers_takehome_set",
112
113
  "careers_settings_set",
113
114
  "rename_resolve",
115
+ "github_merge",
116
+ "github_retitle",
117
+ "github_rerun",
114
118
  "mail_send",
115
119
  "mail_sync",
116
120
  "mail_detach",
@@ -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
+ }
@@ -234,6 +234,11 @@ export const MCP_TWINS = {
234
234
  // BLI-12378: the human's call on whether a correction becomes a rule.
235
235
  "rename list": { tool: "rename_pending", door: "GET /api/jarvis/rename-scope" },
236
236
  "rename resolve": { tool: "rename_resolve", door: "POST /api/jarvis/rename-scope" },
237
+ // BLI-12385: GitHub PR read and the two-step merge / retitle / re-run.
238
+ "github pr": { tool: "github_pr", door: "POST /api/github/pull-request (read)" },
239
+ "github merge": { tool: "github_merge", door: "POST /api/github/pull-request (merge)" },
240
+ "github retitle": { tool: "github_retitle", door: "POST /api/github/pull-request (retitle)" },
241
+ "github rerun": { tool: "github_rerun", door: "POST /api/github/pull-request (rerun)" },
237
242
  "usage sessions": { tool: "usage_sessions", door: "GET /api/usage/sessions" },
238
243
  "usage people": { tool: "usage_people", door: "GET /api/usage/people" },
239
244
  "docs list": { tool: "docs_list", door: "GET /api/docs/documents" },
package/package.json CHANGED
@@ -1,9 +1,16 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.81",
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
  },