@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 +41 -23
- package/dist/agent-door-session.d.ts +3 -0
- package/dist/agent-door-session.js +20 -0
- package/dist/agent-door.d.ts +1 -1
- package/dist/agent-door.js +3 -1
- package/dist/mail-tools.d.ts +3 -1
- package/dist/mail-tools.js +51 -11
- package/dist/remote.d.ts +33 -0
- package/dist/remote.js +68 -0
- package/dist/server-icons.d.ts +16 -0
- package/dist/server-icons.js +17 -0
- package/dist/server.d.ts +3 -1
- package/dist/server.js +12 -7
- package/dist/tool-annotations.d.ts +29 -0
- package/dist/tool-annotations.js +51 -0
- package/dist/tool-catalog.d.ts +1 -1
- package/dist/tool-catalog.js +2 -0
- package/dist/tool-registration.d.ts +32 -0
- package/dist/tool-registration.js +48 -0
- package/dist/verb-census.js +2 -0
- package/package.json +8 -1
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** (
|
|
38
|
-
|
|
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
|
|
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
|
-
**
|
|
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`
|
|
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
|
-
|
|
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
|
|
532
|
-
access_token from `~/.config/bli-event/session.json` and
|
|
533
|
-
transparently.
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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) {
|
package/dist/agent-door.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/agent-door.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/mail-tools.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/mail-tools.js
CHANGED
|
@@ -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 "
|
package/dist/remote.d.ts
ADDED
|
@@ -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
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
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, {
|
|
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
|
+
}
|
package/dist/tool-catalog.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/tool-catalog.js
CHANGED
|
@@ -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/dist/verb-census.js
CHANGED
|
@@ -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.
|
|
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
|
},
|