@bli-cockpit/mcp 0.1.8 → 0.1.10
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 +11 -1
- package/dist/cal-tools.d.ts +36 -0
- package/dist/cal-tools.js +265 -0
- package/dist/jarvis-answer-envelope.d.ts +25 -1
- package/dist/jarvis-answer-envelope.js +2 -0
- package/dist/jarvis-door.js +9 -0
- package/dist/jarvis-tools.js +10 -4
- package/dist/server.js +7 -0
- package/dist/verb-census.js +10 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ about what an agent can reach.
|
|
|
34
34
|
|
|
35
35
|
<!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->
|
|
36
36
|
|
|
37
|
-
**
|
|
37
|
+
**73 of 76 Tower verbs have an MCP twin.**
|
|
38
38
|
Each tool goes through the SAME door its CLI verb calls, with the same
|
|
39
39
|
collector device token — never a second route and never a service-role
|
|
40
40
|
reader. `src/verb-census.test.ts` fails when a verb is in none of the
|
|
@@ -46,6 +46,15 @@ three tables below.
|
|
|
46
46
|
| `cockpit brief read` | `brief_read` | `GET /api/jarvis/brief` |
|
|
47
47
|
| `cockpit brief rewrite` | `brief_rewrite` | `POST /api/jarvis/recompile` |
|
|
48
48
|
| `cockpit brief status` | `brief_status` | `GET /api/ops/brief-status` |
|
|
49
|
+
| `cockpit cal calendars` | `cal_calendars` | `GET /api/cal/calendars` |
|
|
50
|
+
| `cockpit cal create` | `cal_create` | `POST /api/cal/events` |
|
|
51
|
+
| `cockpit cal detach` | `cal_detach` | `DELETE /api/cal/calendars` |
|
|
52
|
+
| `cockpit cal find` | `cal_find` | `GET /api/cal/find` |
|
|
53
|
+
| `cockpit cal next` | `cal_next` | `GET /api/cal/next` |
|
|
54
|
+
| `cockpit cal share` | `cal_share` | `PATCH /api/cal/calendars` |
|
|
55
|
+
| `cockpit cal sync` | `cal_sync` | `POST /api/cal/calendars/[id]/sync` |
|
|
56
|
+
| `cockpit cal today` | `cal_today` | `GET /api/cal/today` |
|
|
57
|
+
| `cockpit cal week` | `cal_week` | `GET /api/cal/week` |
|
|
49
58
|
| `cockpit correct` | `brief_correct` | `POST /api/jarvis/corrections` |
|
|
50
59
|
| `cockpit docs create` | `docs_create` | `POST /api/docs/documents` |
|
|
51
60
|
| `cockpit docs list` | `docs_list` | `GET /api/docs/documents` |
|
|
@@ -120,6 +129,7 @@ A claim about the verb's nature, not a backlog.
|
|
|
120
129
|
| CLI verb | Why it can never have a twin |
|
|
121
130
|
| --- | --- |
|
|
122
131
|
| `cockpit brief edit` | opens the person's own $EDITOR on this machine and files what they changed; an agent has no editor to open (commands/editor.ts) |
|
|
132
|
+
| `cockpit cal add-ical` | carries a calendar's SECRET iCal ADDRESS — a permanent, unauthenticated, read-anything-on-that-calendar URL. The CLI reads it from STDIN precisely so it never lands in an argument list; an MCP tool argument travels through a model's context window and whatever transcript store sits behind it, so attaching a calendar stays a thing a person does at a terminal (BLI-3709) |
|
|
123
133
|
| `cockpit mail add-imap` | carries a Google app password. The CLI reads it from STDIN precisely so it never lands in an argument list; an MCP tool argument travels through a model's context window and whatever transcript store sits behind it, so attaching a mailbox stays a thing a person does at a terminal (BLI-3708) |
|
|
124
134
|
|
|
125
135
|
<!-- END GENERATED verb census -->
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cal_*` MCP tools (BLI-3709) — the calendars a person attached, on the
|
|
3
|
+
* `bli-tower` server, over the same `/api/cal/**` doors `cockpit cal` and the
|
|
4
|
+
* browser call, authenticated with this machine's collector device token.
|
|
5
|
+
*
|
|
6
|
+
* The point, in one sentence: an agent working on somebody's behalf should be
|
|
7
|
+
* able to see that they are in a meeting at 3pm before it proposes a call at
|
|
8
|
+
* 3pm, and should answer "when is standup" from the record rather than from
|
|
9
|
+
* memory.
|
|
10
|
+
*
|
|
11
|
+
* TWO DELIBERATE ASYMMETRIES with the CLI, both recorded in `verb-census.ts`:
|
|
12
|
+
*
|
|
13
|
+
* - **`cockpit cal add-ical` has NO MCP twin.** It carries a calendar's SECRET
|
|
14
|
+
* iCal ADDRESS — a permanent, unauthenticated, read-anything-on-that-calendar
|
|
15
|
+
* URL — and the CLI takes it on stdin precisely so it never lands in an
|
|
16
|
+
* argument list. An MCP tool argument IS an argument list: it travels through
|
|
17
|
+
* a model's context window and whatever transcript store sits behind it.
|
|
18
|
+
* Attaching a calendar stays a thing a person does at a terminal.
|
|
19
|
+
* - **`cal_share` requires `confirm: true`.** Making a calendar readable by
|
|
20
|
+
* everyone at BLI is not a read, and it is not something to do because a
|
|
21
|
+
* sentence sounded like it.
|
|
22
|
+
*
|
|
23
|
+
* EVERY READ SENDS A ZONE. `time_zone` is a required-in-practice argument on
|
|
24
|
+
* the window tools, because a window computed without one is computed in UTC —
|
|
25
|
+
* "today" would end at 5pm in Vancouver. When a caller omits it, the tools
|
|
26
|
+
* send this machine's own zone rather than letting the server guess.
|
|
27
|
+
*
|
|
28
|
+
* Same session discipline as `mail-tools.ts`: the session is loaded fresh per
|
|
29
|
+
* call, so a machine with no `cockpit login` pairing still serves this
|
|
30
|
+
* server's other tools and only a `cal_` call fails, by name.
|
|
31
|
+
*/
|
|
32
|
+
import { type ToolDeps } from "./tool-result.js";
|
|
33
|
+
export type CalDeps = ToolDeps;
|
|
34
|
+
export declare function registerCalTools(server: {
|
|
35
|
+
registerTool: (...args: never[]) => unknown;
|
|
36
|
+
}, deps: CalDeps): void;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cal_*` MCP tools (BLI-3709) — the calendars a person attached, on the
|
|
3
|
+
* `bli-tower` server, over the same `/api/cal/**` doors `cockpit cal` and the
|
|
4
|
+
* browser call, authenticated with this machine's collector device token.
|
|
5
|
+
*
|
|
6
|
+
* The point, in one sentence: an agent working on somebody's behalf should be
|
|
7
|
+
* able to see that they are in a meeting at 3pm before it proposes a call at
|
|
8
|
+
* 3pm, and should answer "when is standup" from the record rather than from
|
|
9
|
+
* memory.
|
|
10
|
+
*
|
|
11
|
+
* TWO DELIBERATE ASYMMETRIES with the CLI, both recorded in `verb-census.ts`:
|
|
12
|
+
*
|
|
13
|
+
* - **`cockpit cal add-ical` has NO MCP twin.** It carries a calendar's SECRET
|
|
14
|
+
* iCal ADDRESS — a permanent, unauthenticated, read-anything-on-that-calendar
|
|
15
|
+
* URL — and the CLI takes it on stdin precisely so it never lands in an
|
|
16
|
+
* argument list. An MCP tool argument IS an argument list: it travels through
|
|
17
|
+
* a model's context window and whatever transcript store sits behind it.
|
|
18
|
+
* Attaching a calendar stays a thing a person does at a terminal.
|
|
19
|
+
* - **`cal_share` requires `confirm: true`.** Making a calendar readable by
|
|
20
|
+
* everyone at BLI is not a read, and it is not something to do because a
|
|
21
|
+
* sentence sounded like it.
|
|
22
|
+
*
|
|
23
|
+
* EVERY READ SENDS A ZONE. `time_zone` is a required-in-practice argument on
|
|
24
|
+
* the window tools, because a window computed without one is computed in UTC —
|
|
25
|
+
* "today" would end at 5pm in Vancouver. When a caller omits it, the tools
|
|
26
|
+
* send this machine's own zone rather than letting the server guess.
|
|
27
|
+
*
|
|
28
|
+
* Same session discipline as `mail-tools.ts`: the session is loaded fresh per
|
|
29
|
+
* call, so a machine with no `cockpit login` pairing still serves this
|
|
30
|
+
* server's other tools and only a `cal_` call fails, by name.
|
|
31
|
+
*/
|
|
32
|
+
import { z } from "zod";
|
|
33
|
+
import { callAgentDoor } from "./agent-door.js";
|
|
34
|
+
import { CONFIRM_INPUT, doorFailureText, errorResult, queryString, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
|
|
35
|
+
/** This machine's zone: what a person means by "today" unless they say otherwise. */
|
|
36
|
+
function localZone() {
|
|
37
|
+
try {
|
|
38
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return "UTC";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function eventLine(event) {
|
|
45
|
+
const when = event.all_day
|
|
46
|
+
? `${event.starts_at.slice(0, 10)} (all day)`
|
|
47
|
+
: `${event.starts_at.slice(0, 16).replace("T", " ")}–${event.ends_at.slice(11, 16)}`;
|
|
48
|
+
const where = event.location ? ` @ ${event.location}` : "";
|
|
49
|
+
const who = event.attendee_count > 0 ? ` (${event.attendee_count} attendees)` : "";
|
|
50
|
+
return `${event.recurring ? "↻" : " "} ${when} ${event.title}${where}${who} [${event.calendar_name}]`;
|
|
51
|
+
}
|
|
52
|
+
/** Every window read, so the three named windows cannot drift from each other. */
|
|
53
|
+
function windowRenderer(label) {
|
|
54
|
+
return (body) => {
|
|
55
|
+
const events = (Array.isArray(body.events) ? body.events : []);
|
|
56
|
+
const calendars = (Array.isArray(body.calendars) ? body.calendars : []);
|
|
57
|
+
const notes = (Array.isArray(body.notes) ? body.notes : []);
|
|
58
|
+
const window = body.window;
|
|
59
|
+
if (calendars.length === 0) {
|
|
60
|
+
return textResult("No calendars are attached. A person attaches one with `cockpit cal add-ical` (personal) or by connecting "
|
|
61
|
+
+ "Google in the browser (work).", body);
|
|
62
|
+
}
|
|
63
|
+
if (events.length === 0) {
|
|
64
|
+
return textResult(`Nothing on ${label} (${window?.time_zone ?? "UTC"}).`, body);
|
|
65
|
+
}
|
|
66
|
+
const unexpanded = notes.length > 0
|
|
67
|
+
? `\n\nNote: ${notes.map((note) => `"${note.title}" repeats on a rule Tower does not expand (${note.detail.join(", ")}), so only its first occurrence is listed`).join("; ")}.`
|
|
68
|
+
: "";
|
|
69
|
+
return textResult(`${events.length} event(s) ${label}, times in ${window?.time_zone ?? "UTC"}:\n${events.map(eventLine).join("\n")}${unexpanded}`, body);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function registerCalTools(server, deps) {
|
|
73
|
+
const register = registrarFor(server);
|
|
74
|
+
const windowInput = {
|
|
75
|
+
time_zone: z.string().min(1).max(80).optional(),
|
|
76
|
+
calendar_id: z.string().uuid().optional(),
|
|
77
|
+
limit: z.number().int().min(1).max(750).optional(),
|
|
78
|
+
};
|
|
79
|
+
function windowQuery(args) {
|
|
80
|
+
const query = new URLSearchParams({ tz: String(args.time_zone ?? localZone()) });
|
|
81
|
+
if (args.calendar_id)
|
|
82
|
+
query.set("calendar_id", String(args.calendar_id));
|
|
83
|
+
if (args.limit)
|
|
84
|
+
query.set("limit", String(args.limit));
|
|
85
|
+
if (typeof args.offset === "number")
|
|
86
|
+
query.set("offset", String(args.offset));
|
|
87
|
+
if (typeof args.hours === "number")
|
|
88
|
+
query.set("hours", String(args.hours));
|
|
89
|
+
return query;
|
|
90
|
+
}
|
|
91
|
+
register("cal_today", {
|
|
92
|
+
title: "What is on today",
|
|
93
|
+
description: "Every event today across every calendar you may read, in the zone you name (default: this machine's). "
|
|
94
|
+
+ "`offset` moves the day — 1 is tomorrow, -1 yesterday. Recurring events are expanded for exactly this day, "
|
|
95
|
+
+ "and a series whose rule Tower will not guess at is named rather than dropped.",
|
|
96
|
+
inputSchema: { ...windowInput, offset: z.number().int().min(-52).max(52).optional() },
|
|
97
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
98
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/cal/today${queryString(windowQuery(args))}`);
|
|
99
|
+
if (!response.ok)
|
|
100
|
+
return errorResult(doorFailureText("cal_today", response));
|
|
101
|
+
return windowRenderer("today")(response.body);
|
|
102
|
+
}));
|
|
103
|
+
register("cal_week", {
|
|
104
|
+
title: "What is on this week",
|
|
105
|
+
description: "Monday to Sunday, across every calendar you may read. The week starts on MONDAY here. `offset` moves it — 1 "
|
|
106
|
+
+ "is next week.",
|
|
107
|
+
inputSchema: { ...windowInput, offset: z.number().int().min(-52).max(52).optional() },
|
|
108
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
109
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/cal/week${queryString(windowQuery(args))}`);
|
|
110
|
+
if (!response.ok)
|
|
111
|
+
return errorResult(doorFailureText("cal_week", response));
|
|
112
|
+
return windowRenderer("this week")(response.body);
|
|
113
|
+
}));
|
|
114
|
+
register("cal_next", {
|
|
115
|
+
title: "What is coming up",
|
|
116
|
+
description: "What is next, counted from RIGHT NOW rather than from midnight — at 23:50 the honest answer is tomorrow's "
|
|
117
|
+
+ "09:30, and a day-shaped window would say 'nothing'. Defaults to the next 72 hours; `hours` narrows or widens "
|
|
118
|
+
+ "it. An event already running is included, so this also answers 'what am I in?'.",
|
|
119
|
+
inputSchema: { ...windowInput, hours: z.number().int().min(1).max(720).optional() },
|
|
120
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
121
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/cal/next${queryString(windowQuery(args))}`);
|
|
122
|
+
if (!response.ok)
|
|
123
|
+
return errorResult(doorFailureText("cal_next", response));
|
|
124
|
+
return windowRenderer("coming up")(response.body);
|
|
125
|
+
}));
|
|
126
|
+
register("cal_find", {
|
|
127
|
+
title: "Find an event",
|
|
128
|
+
description: "Full text over titles, locations and descriptions on every calendar you may read — this is how to answer "
|
|
129
|
+
+ "\"when is standup\". A repeating event is answered with the NEXT time it happens, never with its first "
|
|
130
|
+
+ "occurrence two years ago.",
|
|
131
|
+
inputSchema: {
|
|
132
|
+
query: z.string().min(1).max(400),
|
|
133
|
+
calendar_id: z.string().uuid().optional(),
|
|
134
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
135
|
+
},
|
|
136
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
137
|
+
const query = new URLSearchParams({ q: String(args.query ?? "") });
|
|
138
|
+
if (args.calendar_id)
|
|
139
|
+
query.set("calendar_id", String(args.calendar_id));
|
|
140
|
+
if (args.limit)
|
|
141
|
+
query.set("limit", String(args.limit));
|
|
142
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/cal/find${queryString(query)}`);
|
|
143
|
+
if (!response.ok)
|
|
144
|
+
return errorResult(doorFailureText("cal_find", response));
|
|
145
|
+
const matches = (Array.isArray(response.body.matches) ? response.body.matches : []);
|
|
146
|
+
return textResult(matches.length === 0
|
|
147
|
+
? `Nothing in the calendars you may read matched "${String(args.query)}".`
|
|
148
|
+
: `${matches.length} match(es):\n${matches
|
|
149
|
+
.map((match) => `${match.recurring ? "↻" : " "} ${match.next_occurrence ?? match.starts_at} ${match.title} [${match.calendar_name}]`)
|
|
150
|
+
.join("\n")}`, { matches });
|
|
151
|
+
}));
|
|
152
|
+
register("cal_calendars", {
|
|
153
|
+
title: "List attached calendars",
|
|
154
|
+
description: "Every calendar you may see — its provider (google_oauth or ical_url), whether its owner shared it with the "
|
|
155
|
+
+ "org, and when it last synced. Start here when you need a calendar id.",
|
|
156
|
+
inputSchema: {},
|
|
157
|
+
}, async () => withSession(deps, async (session) => {
|
|
158
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/cal/calendars");
|
|
159
|
+
if (!response.ok)
|
|
160
|
+
return errorResult(doorFailureText("cal_calendars", response));
|
|
161
|
+
const calendars = (Array.isArray(response.body.calendars) ? response.body.calendars : []);
|
|
162
|
+
return textResult(calendars.length === 0
|
|
163
|
+
? "No calendars are attached. A person attaches one with `cockpit cal add-ical` (personal) or by connecting "
|
|
164
|
+
+ "Google in the browser (work)."
|
|
165
|
+
: `${calendars.length} calendar(s).\n${calendars
|
|
166
|
+
.map((calendar) => `${calendar.name} ${calendar.provider} ${calendar.org_visible ? "shared" : "private"} ${calendar.status}${calendar.status_reason ? ` (${calendar.status_reason})` : ""} ${calendar.id}`)
|
|
167
|
+
.join("\n")}`, { calendars });
|
|
168
|
+
}));
|
|
169
|
+
register("cal_create", {
|
|
170
|
+
title: "Create an event",
|
|
171
|
+
description: "Creates an event AT GOOGLE and then stores it here, so the people invited actually get the invitation. Only a "
|
|
172
|
+
+ "google_oauth calendar can be written to — a calendar attached by its secret iCal address is read-only and "
|
|
173
|
+
+ "says so by name. Times are ISO instants, or plain dates with all_day. Requires confirm: true, because "
|
|
174
|
+
+ "attendees are emailed by Google the moment this succeeds.",
|
|
175
|
+
inputSchema: {
|
|
176
|
+
calendar_id: z.string().uuid(),
|
|
177
|
+
title: z.string().min(1).max(500),
|
|
178
|
+
starts_at: z.string().min(4).max(40),
|
|
179
|
+
ends_at: z.string().min(4).max(40),
|
|
180
|
+
location: z.string().max(1_000).optional(),
|
|
181
|
+
all_day: z.boolean().optional(),
|
|
182
|
+
time_zone: z.string().min(1).max(80).optional(),
|
|
183
|
+
attendees: z.array(z.string().email()).max(100).optional(),
|
|
184
|
+
...CONFIRM_INPUT,
|
|
185
|
+
},
|
|
186
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
187
|
+
const refusal = unconfirmed(args, `This would create "${String(args.title)}" and email every attendee from Google.`);
|
|
188
|
+
if (refusal)
|
|
189
|
+
return refusal;
|
|
190
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/cal/events", {
|
|
191
|
+
calendar_id: args.calendar_id,
|
|
192
|
+
title: args.title,
|
|
193
|
+
starts_at: args.starts_at,
|
|
194
|
+
ends_at: args.ends_at,
|
|
195
|
+
...(args.location ? { location: args.location } : {}),
|
|
196
|
+
...(args.all_day === true ? { all_day: true } : {}),
|
|
197
|
+
...(Array.isArray(args.attendees) && args.attendees.length > 0 ? { attendees: args.attendees } : {}),
|
|
198
|
+
time_zone: args.time_zone ?? localZone(),
|
|
199
|
+
});
|
|
200
|
+
if (!response.ok)
|
|
201
|
+
return errorResult(doorFailureText("cal_create", response));
|
|
202
|
+
const event = response.body.event;
|
|
203
|
+
return textResult(`Created "${event?.title ?? String(args.title)}" at ${event?.starts_at ?? String(args.starts_at)}.`
|
|
204
|
+
+ (event?.html_link ? `\n${event.html_link}` : ""), { event });
|
|
205
|
+
}));
|
|
206
|
+
register("cal_share", {
|
|
207
|
+
title: "Share a calendar with the org, or take it back",
|
|
208
|
+
description: "Marks one of YOUR calendars readable by every member of Tower, or private again. Sharing never lets anybody "
|
|
209
|
+
+ "else change it — only the owner writes. Requires confirm: true: making somebody's day visible to nine people "
|
|
210
|
+
+ "is not something to do because a sentence sounded like it.",
|
|
211
|
+
inputSchema: {
|
|
212
|
+
calendar_id: z.string().uuid(),
|
|
213
|
+
org_visible: z.boolean(),
|
|
214
|
+
...CONFIRM_INPUT,
|
|
215
|
+
},
|
|
216
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
217
|
+
const refusal = unconfirmed(args, args.org_visible === true
|
|
218
|
+
? "This would make that calendar readable by everyone at BLI."
|
|
219
|
+
: "This would make that calendar private again.");
|
|
220
|
+
if (refusal)
|
|
221
|
+
return refusal;
|
|
222
|
+
const query = new URLSearchParams({ calendar_id: String(args.calendar_id) });
|
|
223
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "PATCH", `/api/cal/calendars${queryString(query)}`, { org_visible: args.org_visible === true });
|
|
224
|
+
if (!response.ok)
|
|
225
|
+
return errorResult(doorFailureText("cal_share", response));
|
|
226
|
+
const calendar = response.body.calendar;
|
|
227
|
+
return textResult(calendar?.org_visible
|
|
228
|
+
? `"${calendar.name}" is now readable by everyone at BLI. Nobody but its owner can change it.`
|
|
229
|
+
: `"${calendar?.name ?? String(args.calendar_id)}" is private again.`, { calendar });
|
|
230
|
+
}));
|
|
231
|
+
register("cal_sync", {
|
|
232
|
+
title: "Read a calendar now",
|
|
233
|
+
description: "Runs one sync pass for a calendar you OWN, instead of waiting for the 15-minute cron. `full: true` ignores the "
|
|
234
|
+
+ "sync token / ETag and re-reads everything. The answer is the run's own counts and reason label, which is "
|
|
235
|
+
+ "exactly what the ledger row says.",
|
|
236
|
+
inputSchema: { calendar_id: z.string().uuid(), full: z.boolean().optional() },
|
|
237
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
238
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", `/api/cal/calendars/${encodeURIComponent(String(args.calendar_id))}/sync${args.full === true ? "?full=1" : ""}`);
|
|
239
|
+
if (!response.ok)
|
|
240
|
+
return errorResult(doorFailureText("cal_sync", response));
|
|
241
|
+
const run = response.body.run;
|
|
242
|
+
if (!run)
|
|
243
|
+
return textResult("Tower answered without a run. Nothing to report.", response.body);
|
|
244
|
+
return textResult(run.reason === "ok"
|
|
245
|
+
? run.detail === "feed_unchanged"
|
|
246
|
+
? "Nothing has changed on that calendar since the last read."
|
|
247
|
+
: `Synced: ${run.eventsAdded} new, ${run.eventsUpdated} updated, ${run.eventsDeleted} gone.`
|
|
248
|
+
: `That calendar did not sync: ${run.reason}${run.detail ? ` (${run.detail})` : ""}.`, { run });
|
|
249
|
+
}));
|
|
250
|
+
register("cal_detach", {
|
|
251
|
+
title: "Detach a calendar",
|
|
252
|
+
description: "Removes a calendar and its stored events from Tower. The calendar itself is untouched at Google — this only "
|
|
253
|
+
+ "forgets it here. Requires confirm: true.",
|
|
254
|
+
inputSchema: { calendar_id: z.string().uuid(), ...CONFIRM_INPUT },
|
|
255
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
256
|
+
const refusal = unconfirmed(args, "This would detach that calendar and forget its stored events (the calendar itself is untouched at Google).");
|
|
257
|
+
if (refusal)
|
|
258
|
+
return refusal;
|
|
259
|
+
const query = new URLSearchParams({ calendar_id: String(args.calendar_id) });
|
|
260
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "DELETE", `/api/cal/calendars${queryString(query)}`);
|
|
261
|
+
if (!response.ok)
|
|
262
|
+
return errorResult(doorFailureText("cal_detach", response));
|
|
263
|
+
return textResult(`Detached ${String(args.calendar_id)}. Its stored events went with it; the calendar itself is untouched at Google.`, response.body);
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
* Every key of the envelope, in the order it is written. The literal list IS
|
|
54
54
|
* the contract: both copies assert against it, so a drift is a red suite.
|
|
55
55
|
*/
|
|
56
|
-
export declare const JARVIS_ANSWER_ENVELOPE_KEYS: readonly ["ok", "answer", "sources", "turn_id", "thread_id", "trace_thread_id", "proposal_id", "degraded", "degraded_reasons"];
|
|
56
|
+
export declare const JARVIS_ANSWER_ENVELOPE_KEYS: readonly ["ok", "answer", "sources", "turn_id", "thread_id", "trace_thread_id", "proposal_id", "next_action", "degraded", "degraded_reasons"];
|
|
57
57
|
/**
|
|
58
58
|
* One thing that backed the answer (BLI-3770).
|
|
59
59
|
*
|
|
@@ -134,8 +134,25 @@ export interface JarvisAnswerEnvelope {
|
|
|
134
134
|
* could approve with, and that is not approval. The person fetches the code
|
|
135
135
|
* on a surface a person reads and hands it back. Null on every turn that
|
|
136
136
|
* proposed no coding task, which is nearly all of them.
|
|
137
|
+
*
|
|
138
|
+
* BLI-3787: a null here is never on its own. When the arm ran at all,
|
|
139
|
+
* `next_action` below says what happened and what a person has to do — the
|
|
140
|
+
* field was `null` on every proposing turn for two QA ticks while the answer
|
|
141
|
+
* told the caller to spend an id nothing had issued.
|
|
137
142
|
*/
|
|
138
143
|
proposal_id: string | null;
|
|
144
|
+
/**
|
|
145
|
+
* BLI-3787: what has to happen next, in a sentence naming an action a
|
|
146
|
+
* PERSON takes.
|
|
147
|
+
*
|
|
148
|
+
* Today only the coding arm produces one, and it is what makes
|
|
149
|
+
* `proposal_id`'s absence readable: "ask them to fetch the code for proposal
|
|
150
|
+
* X", "nothing was dispatched and no proposal was recorded, a person has to
|
|
151
|
+
* approve this where a person reads", "that approval code has already been
|
|
152
|
+
* used". Null on every turn that never reached the arm, which is nearly all
|
|
153
|
+
* of them — and never a substitute for the answer itself.
|
|
154
|
+
*/
|
|
155
|
+
next_action: string | null;
|
|
139
156
|
degraded: boolean;
|
|
140
157
|
degraded_reasons: JarvisDegradedReason[];
|
|
141
158
|
}
|
|
@@ -173,6 +190,13 @@ export interface JarvisAnswerEnvelopeInput {
|
|
|
173
190
|
}> | null;
|
|
174
191
|
/** `body.proposalId` — BLI-3755's coding-arm proposal id, when the turn made one. */
|
|
175
192
|
proposalId?: string | null;
|
|
193
|
+
/**
|
|
194
|
+
* `body.nextAction` — BLI-3787's sentence for what a person must do next
|
|
195
|
+
* about the coding arm. Absent from a dashboard that predates it, which is
|
|
196
|
+
* why an older deployment reads as `next_action: null` rather than as a
|
|
197
|
+
* turn that said nothing.
|
|
198
|
+
*/
|
|
199
|
+
nextAction?: string | null;
|
|
176
200
|
/**
|
|
177
201
|
* `body.sources` — BLI-3770's structured citations, straight from the door.
|
|
178
202
|
* Typed as `unknown` on purpose: this is another process's JSON, and the
|
|
@@ -61,6 +61,7 @@ export const JARVIS_ANSWER_ENVELOPE_KEYS = [
|
|
|
61
61
|
"thread_id",
|
|
62
62
|
"trace_thread_id",
|
|
63
63
|
"proposal_id",
|
|
64
|
+
"next_action",
|
|
64
65
|
"degraded",
|
|
65
66
|
"degraded_reasons",
|
|
66
67
|
];
|
|
@@ -197,6 +198,7 @@ export function buildJarvisAnswerEnvelope(input) {
|
|
|
197
198
|
thread_id: input.thread ?? null,
|
|
198
199
|
trace_thread_id: input.traceThread ?? null,
|
|
199
200
|
proposal_id: input.proposalId ?? null,
|
|
201
|
+
next_action: input.nextAction ?? null,
|
|
200
202
|
degraded: reasons.length > 0,
|
|
201
203
|
degraded_reasons: reasons,
|
|
202
204
|
};
|
package/dist/jarvis-door.js
CHANGED
|
@@ -110,6 +110,9 @@ export async function takeTurn(deps, session, door, body, remember) {
|
|
|
110
110
|
revised: reply.revised,
|
|
111
111
|
trace: reply.trace,
|
|
112
112
|
proposalId: reply.proposalId,
|
|
113
|
+
// BLI-3787: the sentence beside it, so a refusal names what a person must
|
|
114
|
+
// do rather than arriving as a bare `proposal_id: null`.
|
|
115
|
+
nextAction: reply.nextAction,
|
|
113
116
|
// BLI-3770: the door's own structured citations, when it sent them.
|
|
114
117
|
sources: reply.sources,
|
|
115
118
|
});
|
|
@@ -122,6 +125,9 @@ export async function takeTurn(deps, session, door, body, remember) {
|
|
|
122
125
|
// BLI-3755: whether this turn left a coding-arm proposal behind. The id
|
|
123
126
|
// itself is in the envelope; this line says one was made.
|
|
124
127
|
proposal_recorded: envelope.proposal_id !== null,
|
|
128
|
+
// BLI-3787: whether this turn said what to do next. A turn that reached
|
|
129
|
+
// the coding arm and said nothing is the defect, and it is visible here.
|
|
130
|
+
next_action_present: envelope.next_action !== null,
|
|
125
131
|
degraded: envelope.degraded,
|
|
126
132
|
degraded_reasons: envelope.degraded_reasons,
|
|
127
133
|
has_turn_id: envelope.turn_id !== null,
|
|
@@ -134,6 +140,9 @@ export async function takeTurn(deps, session, door, body, remember) {
|
|
|
134
140
|
? `proposal_id: ${envelope.proposal_id} — the person fetches the approval code for it themself, `
|
|
135
141
|
+ `in a Tower tab, their Slack DM, or their own \`cockpit jarvis\` terminal. You were not sent one.`
|
|
136
142
|
: null,
|
|
143
|
+
// BLI-3787: printed whether or not there is a proposal id, because the
|
|
144
|
+
// turns that made none are exactly the ones a caller could not read.
|
|
145
|
+
envelope.next_action ? `next_action: ${envelope.next_action}` : null,
|
|
137
146
|
envelope.degraded ? `degraded: ${envelope.degraded_reasons.join(", ")}` : null,
|
|
138
147
|
]
|
|
139
148
|
.filter((line) => line !== null)
|
package/dist/jarvis-tools.js
CHANGED
|
@@ -78,13 +78,16 @@ export function registerJarvisTools(server, deps) {
|
|
|
78
78
|
register("jarvis_ask", {
|
|
79
79
|
title: "Ask JARVIS",
|
|
80
80
|
description: "Asks JARVIS one question and returns its answer, the Source: lines behind it, this turn's "
|
|
81
|
-
+ "turn_id
|
|
81
|
+
+ "turn_id, the conversation's thread_id, and — when the turn reached the coding arm — a "
|
|
82
|
+
+ "proposal_id and a next_action sentence. Same JARVIS, same tool belt and same evidence "
|
|
82
83
|
+ "rules as Tower web chat, the Slack DM and `cockpit jarvis`. A thread is SINGLE-WRITER: "
|
|
83
84
|
+ "omit thread and this turn gets a FRESH conversation of its own, so two questions asked at "
|
|
84
85
|
+ "the same time can never read each other's; pass the thread_id the answer returned to "
|
|
85
86
|
+ "continue that conversation deliberately. Pass the returned turn_id to jarvis_trace to see "
|
|
86
87
|
+ "how the answer was reached. JARVIS speaks as the person this machine is paired to and "
|
|
87
|
-
+ "cannot be made to speak as anyone else."
|
|
88
|
+
+ "cannot be made to speak as anyone else. proposal_id is null on every turn that proposed no "
|
|
89
|
+
+ "coding task, including one the arm refused; next_action is the sentence saying which it "
|
|
90
|
+
+ "was and what a PERSON has to do about it, so a null proposal_id is never on its own.",
|
|
88
91
|
inputSchema: {
|
|
89
92
|
question: z.string().min(1).max(50_000).describe("What to ask, in plain words."),
|
|
90
93
|
thread: z
|
|
@@ -194,10 +197,13 @@ export function registerJarvisTools(server, deps) {
|
|
|
194
197
|
+ "approval_code: you get the plan and a proposal_id back, and DELIBERATELY no code — a code "
|
|
195
198
|
+ "you can read is a code you could approve with. Show the person the plan and the "
|
|
196
199
|
+ "proposal_id and ask them to fetch the code themself, in a Tower browser tab, their Slack "
|
|
197
|
-
+ "DM with JARVIS, or their own `cockpit jarvis
|
|
200
|
+
+ "DM with JARVIS, or their own interactive `cockpit jarvis`. Then call this again with the code "
|
|
198
201
|
+ "THEY give you and the same instruction. A code approves exactly ONE run: a reused one is "
|
|
199
202
|
+ "refused (approval_code_spent) and the answer is a fresh approval, never a retry. Never "
|
|
200
|
-
+ "derive, guess, or reuse a code the person has not just handed you."
|
|
203
|
+
+ "derive, guess, or reuse a code the person has not just handed you. If the answer comes "
|
|
204
|
+
+ "back with no proposal_id, read next_action: the arm refused rather than proposed, and it "
|
|
205
|
+
+ "names what a person has to do — never send a proposal_id back to JARVIS yourself, this is "
|
|
206
|
+
+ "the surface it was withheld from and the read-back is refused here.",
|
|
201
207
|
inputSchema: {
|
|
202
208
|
instruction: z
|
|
203
209
|
.string()
|
package/dist/server.js
CHANGED
|
@@ -10,6 +10,7 @@ import { registerBriefTools } from "./brief-tools.js";
|
|
|
10
10
|
import { registerBriefWriteTools } from "./brief-write-tools.js";
|
|
11
11
|
import { registerDocsMsgTools } from "./docs-msg-tools.js";
|
|
12
12
|
import { registerJarvisTools } from "./jarvis-tools.js";
|
|
13
|
+
import { registerCalTools } from "./cal-tools.js";
|
|
13
14
|
import { registerMailTools } from "./mail-tools.js";
|
|
14
15
|
import { registerNotesTools } from "./notes-tools.js";
|
|
15
16
|
import { registerNotesWriteTools } from "./notes-write-tools.js";
|
|
@@ -392,6 +393,12 @@ export function createServer(deps) {
|
|
|
392
393
|
// has no twin (see mail-tools.ts's header — a credential is not a tool
|
|
393
394
|
// argument).
|
|
394
395
|
registerMailTools(server, { fetchImpl: deps.fetchImpl });
|
|
396
|
+
// BLI-3709: the calendars a person attached. The window reads, a find, a
|
|
397
|
+
// create and a share over the same /api/cal/** doors `cockpit cal` calls;
|
|
398
|
+
// `add-ical` deliberately has no twin (see cal-tools.ts's header — a
|
|
399
|
+
// calendar's secret address is a credential, and a credential is not a tool
|
|
400
|
+
// argument).
|
|
401
|
+
registerCalTools(server, { fetchImpl: deps.fetchImpl });
|
|
395
402
|
// BLI-3732: `jarvis_*` — the assistant itself, on the same device-token path
|
|
396
403
|
// as the three families above, over the doors `cockpit jarvis` already
|
|
397
404
|
// calls. It is the last Tower surface that had a CLI door and no MCP one.
|
package/dist/verb-census.js
CHANGED
|
@@ -156,6 +156,15 @@ export const MCP_TWINS = {
|
|
|
156
156
|
"mail attachment": { tool: "mail_attachment", door: "GET /api/mail/attachments/[id]?meta=1" },
|
|
157
157
|
"mail sync": { tool: "mail_sync", door: "POST /api/mail/accounts/[id]/sync" },
|
|
158
158
|
"mail detach": { tool: "mail_detach", door: "DELETE /api/mail/accounts" },
|
|
159
|
+
"cal today": { tool: "cal_today", door: "GET /api/cal/today" },
|
|
160
|
+
"cal week": { tool: "cal_week", door: "GET /api/cal/week" },
|
|
161
|
+
"cal next": { tool: "cal_next", door: "GET /api/cal/next" },
|
|
162
|
+
"cal find": { tool: "cal_find", door: "GET /api/cal/find" },
|
|
163
|
+
"cal calendars": { tool: "cal_calendars", door: "GET /api/cal/calendars" },
|
|
164
|
+
"cal create": { tool: "cal_create", door: "POST /api/cal/events" },
|
|
165
|
+
"cal share": { tool: "cal_share", door: "PATCH /api/cal/calendars" },
|
|
166
|
+
"cal sync": { tool: "cal_sync", door: "POST /api/cal/calendars/[id]/sync" },
|
|
167
|
+
"cal detach": { tool: "cal_detach", door: "DELETE /api/cal/calendars" },
|
|
159
168
|
"brief read": { tool: "brief_read", door: "GET /api/jarvis/brief" },
|
|
160
169
|
"brief history": { tool: "brief_history", door: "GET /api/jarvis/brief?history=1" },
|
|
161
170
|
"brief status": { tool: "brief_status", door: "GET /api/ops/brief-status" },
|
|
@@ -203,6 +212,7 @@ export const MCP_TWINS = {
|
|
|
203
212
|
/** Verbs that can never have an MCP twin, and why. A claim, not a backlog. */
|
|
204
213
|
export const TERMINAL_ONLY = {
|
|
205
214
|
"mail add-imap": "carries a Google app password. The CLI reads it from STDIN precisely so it never lands in an argument list; an MCP tool argument travels through a model's context window and whatever transcript store sits behind it, so attaching a mailbox stays a thing a person does at a terminal (BLI-3708)",
|
|
215
|
+
"cal add-ical": "carries a calendar's SECRET iCal ADDRESS — a permanent, unauthenticated, read-anything-on-that-calendar URL. The CLI reads it from STDIN precisely so it never lands in an argument list; an MCP tool argument travels through a model's context window and whatever transcript store sits behind it, so attaching a calendar stays a thing a person does at a terminal (BLI-3709)",
|
|
206
216
|
"brief edit": "opens the person's own $EDITOR on this machine and files what they changed; an agent has no editor to open (commands/editor.ts)",
|
|
207
217
|
};
|
|
208
218
|
/**
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "bli-tower
|
|
5
|
+
"description": "bli-tower \u2014 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
7
|
"bin": {
|
|
8
8
|
"bli-cockpit-mcp": "./dist/index.js"
|