@bli-cockpit/mcp 0.1.59 → 0.1.61

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
@@ -25,6 +25,75 @@ dead"). `src/verb-census.test.ts` is that rule as a test — it reads the CLI's
25
25
  own decision tables and fails when a verb is in neither the twin map, the
26
26
  terminal-only list, nor the written follow-up queue.
27
27
 
28
+ ## MCP or CLI: which one should an agent use?
29
+
30
+ Both reach the same Tower doors with the same device token and the same
31
+ refusal words, so the choice is about where the agent runs and what it costs.
32
+
33
+ - **The agent has a shell** (Claude Code, Codex, any terminal agent): use the
34
+ CLI, `cockpit <noun> <verb>`. Nothing loads into the context window until a
35
+ command runs, the human-readable output is the tersest form of every read,
36
+ 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.
40
+
41
+ Measured on 2026-09-26 against production, same account, same moment. Bytes
42
+ the model actually reads; tokens are an estimate at about 4 bytes a token.
43
+
44
+ | Read | `cockpit …` text | `cockpit … --json` | MCP text half (Claude hosts) | MCP structured half (Codex) |
45
+ | --- | ---: | ---: | ---: | ---: |
46
+ | issue list, 20 issues | 2,345 B (~0.6k tok) | 43,964 B (~11k tok) | 3,632 B (~0.9k tok) | 6,865 B (~1.7k tok) |
47
+ | mail inbox, 10 messages | 1,070 B (~0.3k tok) | 12,583 B (~3.1k tok) | 1,475 B (~0.4k tok) | 9,544 B (~2.4k tok) |
48
+ | today's brief | 2,374 B (~0.6k tok) | 4,032 B (~1k tok) | 2,262 B (~0.6k tok) | 2,635 B (~0.7k tok) |
49
+
50
+ Two costs sit outside the table. The MCP server's tool list is 109 tool
51
+ definitions, 94,985 bytes (~24k tokens); a host that loads every schema up
52
+ front pays that once per session before the first call, and a host that defers
53
+ schemas until a tool is searched for pays a fraction of it. The CLI's
54
+ equivalent is a `--help` read when the agent does not know a verb: 1,769 bytes
55
+ for `cockpit issue list --help`, 8,998 for the whole `cockpit --help`. And
56
+ `--json` is the CLI's most expensive form, because it prints the door's whole
57
+ answer (`issue list --json` carries every description); read the text form
58
+ unless a script needs the fields.
59
+
60
+ **Which half a host shows the model.** A tool result has a text half
61
+ (`content`) and a structured half (`structuredContent`), and hosts disagree:
62
+ Claude Code, claude.ai and Claude Desktop give the model the text half; Codex
63
+ gives it the structured half and drops the text. Every read on this server is
64
+ written so EACH half stands alone. That was not always true: until BLI-10432,
65
+ `docs_list`, `msg_channels`, `msg_read`, every `careers_*` tool,
66
+ `usage_people`, `usage_sessions` and `memory_consolidation` answered a
67
+ headline such as `5 document(s).` in text, so on a Claude host the model was
68
+ told the data existed and never given it. `src/tool-contract.test.ts` holds
69
+ the rule.
70
+
71
+ **A default read is affordable.** A list with no `limit` answers a page and
72
+ says when more exist: `docs_list`, `work_list_issues` and `notes_list` return
73
+ 50 (`work_list_issues` with no limit used to return 1,000 issues with their
74
+ descriptions, 4.2 MB). `ops_status` returns the fleet and coverage summaries
75
+ and keeps the per-device and per-person rows behind `people: true` (they were
76
+ 155 KB of a 175 KB answer). Every door gets 55 seconds, under the 60 seconds a
77
+ stock MCP client waits; `ops_status` and `usage_sessions` routinely take 20-35
78
+ seconds and used to be abandoned at 15. Each call logs one metadata line to
79
+ stderr, the host's MCP log: `[bli-cockpit-mcp tool] info
80
+ {"tool":…,"outcome":…,"ms":…,"text_bytes":…,"structured_bytes":…}`, a `warn`
81
+ when a half crosses 32 KB (`RESULT_BUDGET_BYTES`).
82
+
83
+ **Schemas every client compiles.** The published input schemas use only the
84
+ portable JSON-schema keywords (no `exclusiveMinimum`, no `propertyNames`) and
85
+ no lookaround in a pattern, because OpenAI's strict mode and Google's function
86
+ declarations compile patterns with RE2, and one refused tool can make a strict
87
+ host refuse the whole server. `src/tool-contract.test.ts` reads the schemas
88
+ from a real server over a real client and fails on either.
89
+
90
+ **Proving it answers.** `npm run mcp:smoke --workspace=@bli-cockpit/mcp`
91
+ calls every read and dry run in `src/tool-catalog.ts` against the Tower this
92
+ machine is paired with, never a write, and fails a list read over 32 KB a
93
+ half. `--no-model` leaves out the two reads that spend a model turn.
94
+ `src/tool-catalog.test.ts` fails when a registered tool is in none of the
95
+ catalog's read, dry-run and write lists.
96
+
28
97
  ## Every Tower verb, and the tool that does the same thing
29
98
 
30
99
  The three tables below are GENERATED from `src/verb-census.ts` by
@@ -12,6 +12,18 @@ export interface AgentDoorResponse {
12
12
  /** Set only when no JSON answer was produced at all — DNS, TLS, timeout, non-JSON body. */
13
13
  transportError: string | null;
14
14
  }
15
+ /**
16
+ * How long a door gets before this server gives up on it (BLI-10432).
17
+ *
18
+ * It was 15 s, and two real doors take longer than that on production:
19
+ * `GET /api/ops/status` (the whole board) answered in 23.5 s and
20
+ * `GET /api/usage/sessions?task=…` in 24 s on 2026-09-26. `cockpit` sets no
21
+ * deadline on either, so the CLI answered and the MCP twin reported "Tower
22
+ * could not be reached" for a door that was simply working. 55 s sits under
23
+ * the 60 s request deadline the MCP TypeScript client applies by default, so
24
+ * this server still answers by name before a stock client hangs up on it.
25
+ */
26
+ export declare const DEFAULT_TIMEOUT_MS = 55000;
15
27
  /**
16
28
  * The four verbs Tower's agent doors answer to. `PUT` is the CLI floor
17
29
  * (`PUT /api/settings/cli-floor`) and `DELETE` is an env blob — both arrived
@@ -3,7 +3,18 @@
3
3
  * token (BLI-3706). Deadline-bounded; a transport failure and a door refusal
4
4
  * are kept apart, same rule as `packages/cockpit-memory-mcp/src/door.ts`.
5
5
  */
6
- const DEFAULT_TIMEOUT_MS = 15_000;
6
+ /**
7
+ * How long a door gets before this server gives up on it (BLI-10432).
8
+ *
9
+ * It was 15 s, and two real doors take longer than that on production:
10
+ * `GET /api/ops/status` (the whole board) answered in 23.5 s and
11
+ * `GET /api/usage/sessions?task=…` in 24 s on 2026-09-26. `cockpit` sets no
12
+ * deadline on either, so the CLI answered and the MCP twin reported "Tower
13
+ * could not be reached" for a door that was simply working. 55 s sits under
14
+ * the 60 s request deadline the MCP TypeScript client applies by default, so
15
+ * this server still answers by name before a stock client hangs up on it.
16
+ */
17
+ export const DEFAULT_TIMEOUT_MS = 55_000;
7
18
  export async function callAgentDoor(session, fetchImpl, method, path, body, timeoutMs = DEFAULT_TIMEOUT_MS) {
8
19
  const url = `${session.dashboardUrl}${path}`;
9
20
  const controller = new AbortController();
package/dist/cal-tools.js CHANGED
@@ -31,7 +31,7 @@
31
31
  */
32
32
  import { z } from "zod";
33
33
  import { callAgentDoor } from "./agent-door.js";
34
- import { CONFIRM_INPUT, doorFailureText, errorResult, queryString, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
34
+ import { CONFIRM_INPUT, EMAIL_INPUT, doorFailureText, errorResult, queryString, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
35
35
  /** This machine's zone: what a person means by "today" unless they say otherwise. */
36
36
  function localZone() {
37
37
  try {
@@ -73,9 +73,9 @@ function windowRenderer(label) {
73
73
  export function registerCalTools(server, deps) {
74
74
  const register = registrarFor(server);
75
75
  const windowInput = {
76
- time_zone: z.string().min(1).max(80).optional(),
77
- calendar_id: z.string().uuid().optional(),
78
- limit: z.number().int().min(1).max(750).optional(),
76
+ time_zone: z.string().min(1).max(80).optional().describe("An IANA zone, e.g. Asia/Saigon. Default: this machine's zone."),
77
+ calendar_id: z.string().uuid().optional().describe("One calendar id (cal_calendars). Omit for every calendar you may read."),
78
+ limit: z.number().int().min(1).max(750).optional().describe("At most this many events."),
79
79
  };
80
80
  function windowQuery(args) {
81
81
  const query = new URLSearchParams({ tz: String(args.time_zone ?? localZone()) });
@@ -94,7 +94,7 @@ export function registerCalTools(server, deps) {
94
94
  description: "Every event today across every calendar you may read, in the zone you name (default: this machine's). "
95
95
  + "`offset` moves the day — 1 is tomorrow, -1 yesterday. Recurring events are expanded for exactly this day, "
96
96
  + "and a series whose rule Tower will not guess at is named rather than dropped.",
97
- inputSchema: { ...windowInput, offset: z.number().int().min(-52).max(52).optional() },
97
+ inputSchema: { ...windowInput, offset: z.number().int().min(-52).max(52).optional().describe("Days from today: 1 is tomorrow, -1 yesterday.") },
98
98
  }, async (args) => withSession(deps, async (session) => {
99
99
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/cal/today${queryString(windowQuery(args))}`);
100
100
  if (!response.ok)
@@ -105,7 +105,7 @@ export function registerCalTools(server, deps) {
105
105
  title: "What is on this week",
106
106
  description: "Monday to Sunday, across every calendar you may read. The week starts on MONDAY here. `offset` moves it — 1 "
107
107
  + "is next week.",
108
- inputSchema: { ...windowInput, offset: z.number().int().min(-52).max(52).optional() },
108
+ inputSchema: { ...windowInput, offset: z.number().int().min(-52).max(52).optional().describe("Weeks from this one: 1 is next week, -1 last week.") },
109
109
  }, async (args) => withSession(deps, async (session) => {
110
110
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/cal/week${queryString(windowQuery(args))}`);
111
111
  if (!response.ok)
@@ -117,7 +117,7 @@ export function registerCalTools(server, deps) {
117
117
  description: "What is next, counted from RIGHT NOW rather than from midnight — at 23:50 the honest answer is tomorrow's "
118
118
  + "09:30, and a day-shaped window would say 'nothing'. Defaults to the next 72 hours; `hours` narrows or widens "
119
119
  + "it. An event already running is included, so this also answers 'what am I in?'.",
120
- inputSchema: { ...windowInput, hours: z.number().int().min(1).max(720).optional() },
120
+ inputSchema: { ...windowInput, hours: z.number().int().min(1).max(720).optional().describe("How far ahead to look, in hours. Default 72.") },
121
121
  }, async (args) => withSession(deps, async (session) => {
122
122
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/cal/next${queryString(windowQuery(args))}`);
123
123
  if (!response.ok)
@@ -130,9 +130,9 @@ export function registerCalTools(server, deps) {
130
130
  + "\"when is standup\". A repeating event is answered with the NEXT time it happens, never with its first "
131
131
  + "occurrence two years ago.",
132
132
  inputSchema: {
133
- query: z.string().min(1).max(400),
134
- calendar_id: z.string().uuid().optional(),
135
- limit: z.number().int().min(1).max(100).optional(),
133
+ query: z.string().min(1).max(400).describe("Words to find in an event's title, location or description."),
134
+ calendar_id: z.string().uuid().optional().describe("One calendar id (cal_calendars). Omit for every calendar you may read."),
135
+ limit: z.number().int().min(1).max(100).optional().describe("At most this many matches."),
136
136
  },
137
137
  }, async (args) => withSession(deps, async (session) => {
138
138
  const query = new URLSearchParams({ q: String(args.query ?? "") });
@@ -174,14 +174,14 @@ export function registerCalTools(server, deps) {
174
174
  + "says so by name. Times are ISO instants, or plain dates with all_day. Requires confirm: true, because "
175
175
  + "attendees are emailed by Google the moment this succeeds.",
176
176
  inputSchema: {
177
- calendar_id: z.string().uuid(),
178
- title: z.string().min(1).max(500),
179
- starts_at: z.string().min(4).max(40),
180
- ends_at: z.string().min(4).max(40),
181
- location: z.string().max(1_000).optional(),
182
- all_day: z.boolean().optional(),
183
- time_zone: z.string().min(1).max(80).optional(),
184
- attendees: z.array(z.string().email()).max(100).optional(),
177
+ calendar_id: z.string().uuid().describe("A google_oauth calendar id you own (cal_calendars)."),
178
+ title: z.string().min(1).max(500).describe("The event title."),
179
+ starts_at: z.string().min(4).max(40).describe("An ISO instant, or a YYYY-MM-DD date with all_day."),
180
+ ends_at: z.string().min(4).max(40).describe("An ISO instant, or a YYYY-MM-DD date with all_day."),
181
+ location: z.string().max(1_000).optional().describe("Where it happens."),
182
+ all_day: z.boolean().optional().describe("true: starts_at and ends_at are dates, not instants."),
183
+ time_zone: z.string().min(1).max(80).optional().describe("An IANA zone for the event. Default: this machine's zone."),
184
+ attendees: z.array(EMAIL_INPUT).max(100).optional().describe("Email addresses to invite. Google emails each one."),
185
185
  meet: z.boolean().optional().describe("Attach a Google Meet room (BLI-3810). If Google refuses, the event is still created and the answer says meet_not_available."),
186
186
  confirm: CONFIRM_INPUT,
187
187
  },
@@ -217,8 +217,8 @@ export function registerCalTools(server, deps) {
217
217
  + "else change it — only the owner writes. Requires confirm: true: making somebody's day visible to nine people "
218
218
  + "is not something to do because a sentence sounded like it.",
219
219
  inputSchema: {
220
- calendar_id: z.string().uuid(),
221
- org_visible: z.boolean(),
220
+ calendar_id: z.string().uuid().describe("A calendar id you own (cal_calendars)."),
221
+ org_visible: z.boolean().describe("true: everyone at the org may read it. false: private again."),
222
222
  confirm: CONFIRM_INPUT,
223
223
  },
224
224
  }, async (args) => withSession(deps, async (session) => {
@@ -241,7 +241,10 @@ export function registerCalTools(server, deps) {
241
241
  description: "Runs one sync pass for a calendar you OWN, instead of waiting for the 15-minute cron. `full: true` ignores the "
242
242
  + "sync token / ETag and re-reads everything. The answer is the run's own counts and reason label, which is "
243
243
  + "exactly what the ledger row says.",
244
- inputSchema: { calendar_id: z.string().uuid(), full: z.boolean().optional() },
244
+ inputSchema: {
245
+ calendar_id: z.string().uuid().describe("A calendar id you own (cal_calendars)."),
246
+ full: z.boolean().optional().describe("true: ignore the sync token and re-read everything."),
247
+ },
245
248
  }, async (args) => withSession(deps, async (session) => {
246
249
  const response = await callAgentDoor(session, deps.fetchImpl, "POST", `/api/cal/calendars/${encodeURIComponent(String(args.calendar_id))}/sync${args.full === true ? "?full=1" : ""}`);
247
250
  if (!response.ok)
@@ -259,7 +262,7 @@ export function registerCalTools(server, deps) {
259
262
  title: "Detach a calendar",
260
263
  description: "Removes a calendar and its stored events from Tower. The calendar itself is untouched at Google — this only "
261
264
  + "forgets it here. Requires confirm: true.",
262
- inputSchema: { calendar_id: z.string().uuid(), confirm: CONFIRM_INPUT },
265
+ inputSchema: { calendar_id: z.string().uuid().describe("A calendar id you own (cal_calendars)."), confirm: CONFIRM_INPUT },
263
266
  }, async (args) => withSession(deps, async (session) => {
264
267
  const refusal = unconfirmed(args, "This would detach that calendar and forget its stored events (the calendar itself is untouched at Google).");
265
268
  if (refusal)
@@ -1,5 +1,11 @@
1
1
  import { type ToolDeps } from './tool-result.js';
2
2
  /**
3
+ * Every result here is a `dataResult`: the door's body rides in the text half
4
+ * as well as the structured one. These tools used to answer with one word
5
+ * ("Applications", "Resume link") in text, so on a Claude-family client, which
6
+ * shows the model only the text, the resume URL and every applicant were
7
+ * invisible (BLI-10432).
8
+ *
3
9
  * `careers_*` — the MCP twins of `cockpit careers`, over the same doors.
4
10
  *
5
11
  * BLI-4460 added the pipeline half: inviting, deciding, the role's take-home
@@ -1,7 +1,32 @@
1
1
  import { z } from 'zod';
2
2
  import { callAgentDoor } from './agent-door.js';
3
- import { doorFailureText, errorResult, registrarFor, textResult, withSession } from './tool-result.js';
3
+ import { dataResult, doorFailureText, errorResult, registrarFor, withSession } from './tool-result.js';
4
+ /** Verbatim from `lib/careers/applications.ts` `PIPELINE_STAGES`. */
5
+ const PIPELINE_STAGES = ['screened', 'invited', 'submitted', 'graded', 'booking_sent', 'passed_on', 'archived'];
6
+ const APPLICATION_ID = z.string().uuid().describe('An application id, as careers_list returns it.');
4
7
  /**
8
+ * One list row without the screening's prose (BLI-10432). The door returns the
9
+ * whole screening object per row, summary sentences and quoted evidence
10
+ * included: about 2 KB an applicant, so the door's own 100-row page is ~200 KB.
11
+ * The list keeps what triage sorts on; careers_show reads the rest.
12
+ */
13
+ function applicationListRow(row) {
14
+ const screening = row.screening && typeof row.screening === 'object' ? row.screening : {};
15
+ const { screening: _screening, ...rest } = row;
16
+ return {
17
+ ...rest,
18
+ score: typeof screening.score === 'number' ? screening.score : null,
19
+ recommend: typeof screening.recommend === 'string' ? screening.recommend : null,
20
+ flags: Array.isArray(screening.flags) ? screening.flags : [],
21
+ };
22
+ }
23
+ /**
24
+ * Every result here is a `dataResult`: the door's body rides in the text half
25
+ * as well as the structured one. These tools used to answer with one word
26
+ * ("Applications", "Resume link") in text, so on a Claude-family client, which
27
+ * shows the model only the text, the resume URL and every applicant were
28
+ * invisible (BLI-10432).
29
+ *
5
30
  * `careers_*` — the MCP twins of `cockpit careers`, over the same doors.
6
31
  *
7
32
  * BLI-4460 added the pipeline half: inviting, deciding, the role's take-home
@@ -17,18 +42,40 @@ import { doorFailureText, errorResult, registrarFor, textResult, withSession } f
17
42
  */
18
43
  export function registerCareersTools(server, deps) {
19
44
  const register = registrarFor(server);
20
- register('careers_list', { title: 'List applications', description: 'Super-admin only. Newest applications, bounded to 100 with total and has_more, plus the two thresholds in force.', inputSchema: { role: z.string().optional(), min_score: z.number().min(0).max(100).optional(), since: z.string().optional(), stage: z.string().optional() } }, async (args) => withSession(deps, async (session) => {
45
+ register('careers_list', {
46
+ title: 'List applications',
47
+ description: 'Super-admin only. Newest applications first: id, name, role, stage, screening score, recommendation and flags, with total and has_more, plus the two thresholds in force. Page with limit (default 100, max 200) and offset. The screening prose is not in the list; careers_show reads one application in full.',
48
+ inputSchema: {
49
+ role: z.string().min(1).max(200).optional().describe('A role slug, e.g. fde-intern-2027 (the role_slug careers_list returns).'),
50
+ min_score: z.number().min(0).max(100).optional().describe('Only applications whose screening scored at least this (0-100).'),
51
+ since: z.string().min(1).max(40).optional().describe('ISO-8601 instant with an offset, e.g. 2026-09-01T00:00:00Z: applied at or after.'),
52
+ until: z.string().min(1).max(40).optional().describe('ISO-8601 instant with an offset: applied before.'),
53
+ stage: z.enum(PIPELINE_STAGES).optional().describe('Only applications at this pipeline stage.'),
54
+ limit: z.number().int().min(1).max(200).optional().describe('At most this many rows. Default 100.'),
55
+ offset: z.number().int().min(0).max(100_000).optional().describe('Skip this many rows first, for the next page.'),
56
+ },
57
+ }, async (args) => withSession(deps, async (session) => {
21
58
  const query = new URLSearchParams();
22
- for (const key of ['role', 'min_score', 'since', 'stage'])
59
+ for (const key of ['role', 'min_score', 'since', 'until', 'stage', 'limit', 'offset'])
23
60
  if (args[key] !== undefined)
24
61
  query.set(key, String(args[key]));
25
62
  const answer = await callAgentDoor(session, deps.fetchImpl, 'GET', `/api/careers/applications?${query}`);
26
- return answer.ok ? textResult('Applications', answer.body) : errorResult(doorFailureText('careers_list', answer));
63
+ if (!answer.ok)
64
+ return errorResult(doorFailureText('careers_list', answer));
65
+ const rows = (Array.isArray(answer.body.applications) ? answer.body.applications : []);
66
+ const body = { ...answer.body, applications: rows.map(applicationListRow) };
67
+ return dataResult(`${rows.length} of ${String(answer.body.total ?? rows.length)} application(s)${answer.body.has_more === true ? ', more with offset' : ''}.`, body);
27
68
  }));
28
69
  for (const action of ['show', 'rescreen'])
29
- register(`careers_${action}`, { title: `${action} application`, description: `Super-admin only. ${action} one application.`, inputSchema: { id: z.string().uuid() } }, async (args) => withSession(deps, async (session) => {
70
+ register(`careers_${action}`, {
71
+ title: action === 'show' ? 'Show one application' : 'Screen one application again',
72
+ description: action === 'show'
73
+ ? 'Super-admin only. One application in full: the answers, the screening (score, summary, quoted evidence, flags), stage and grade.'
74
+ : 'Super-admin only. Runs the screening again for one application and stores the new result. Spends one model call.',
75
+ inputSchema: { id: APPLICATION_ID },
76
+ }, async (args) => withSession(deps, async (session) => {
30
77
  const answer = await callAgentDoor(session, deps.fetchImpl, action === 'show' ? 'GET' : 'POST', `/api/careers/applications/${encodeURIComponent(String(args.id))}${action === 'rescreen' ? '/rescreen' : ''}`, undefined, 60_000);
31
- return answer.ok ? textResult('Application', answer.body) : errorResult(doorFailureText(`careers_${action}`, answer));
78
+ return answer.ok ? dataResult(action === 'show' ? 'Application' : 'Screened again', answer.body) : errorResult(doorFailureText(`careers_${action}`, answer));
32
79
  }));
33
80
  // Two names, ONE door (BLI-4460 follow-up). Tower's button says "Send
34
81
  // take-home" and this tool said `invite`, so an agent reading the screen and
@@ -43,18 +90,18 @@ export function registerCareersTools(server, deps) {
43
90
  register(tool, {
44
91
  title: 'Send the take-home',
45
92
  description,
46
- inputSchema: { id: z.string().uuid() },
93
+ inputSchema: { id: APPLICATION_ID },
47
94
  }, async (args) => withSession(deps, async (session) => {
48
95
  const answer = await callAgentDoor(session, deps.fetchImpl, 'POST', `/api/careers/applications/${encodeURIComponent(String(args.id))}/invite`, undefined, 60_000);
49
- return answer.ok ? textResult('Invitation sent', answer.body) : errorResult(doorFailureText(tool, answer));
96
+ return answer.ok ? dataResult('Invitation sent', answer.body) : errorResult(doorFailureText(tool, answer));
50
97
  }));
51
98
  register('careers_grade', {
52
99
  title: 'Re-queue a take-home for grading',
53
- description: 'Super-admin only. Puts one submitted or graded application back in the take-home grader\'s queue. The grader runs on Railway every 15 minutes because it clones a repository and reads a video; `now` is answered honestly rather than run here.',
54
- inputSchema: { id: z.string().uuid(), now: z.boolean().optional() },
100
+ description: 'Super-admin only. Puts one submitted or graded application back in the take-home grader\'s queue. The grader runs on Railway every 5 minutes because it clones a repository and reads a video; `now` is answered honestly rather than run here.',
101
+ inputSchema: { id: APPLICATION_ID, now: z.boolean().optional().describe('Ask for an immediate grade. Answered honestly: the grader only runs on its Railway schedule.') },
55
102
  }, async (args) => withSession(deps, async (session) => {
56
103
  const answer = await callAgentDoor(session, deps.fetchImpl, 'POST', `/api/careers/applications/${encodeURIComponent(String(args.id))}/grade`, { now: args.now === true }, 60_000);
57
- return answer.ok ? textResult('Queued for grading', answer.body) : errorResult(doorFailureText('careers_grade', answer));
104
+ return answer.ok ? dataResult('Queued for grading', answer.body) : errorResult(doorFailureText('careers_grade', answer));
58
105
  }));
59
106
  // BLI-4462. The drawer has an "Open resume" button and this surface had no
60
107
  // word for it, so an agent could read every screening line about a candidate
@@ -65,7 +112,7 @@ export function registerCareersTools(server, deps) {
65
112
  register('careers_resume_url', {
66
113
  title: 'Link to a resume',
67
114
  description: 'Super-admin only. A signed link to one applicant\'s resume file, the same one the Open resume button uses. THE LINK DIES IN 60 SECONDS: fetch it and read it in the same turn, never store it. An application with no resume on file is refused rather than answered with an empty link.',
68
- inputSchema: { id: z.string().uuid() },
115
+ inputSchema: { id: APPLICATION_ID },
69
116
  }, async (args) => withSession(deps, async (session) => {
70
117
  const answer = await callAgentDoor(session, deps.fetchImpl, 'GET', `/api/careers/applications/${encodeURIComponent(String(args.id))}/resume-url`);
71
118
  if (!answer.ok)
@@ -74,39 +121,39 @@ export function registerCareersTools(server, deps) {
74
121
  // `expires_in_seconds`, because 60 with no unit reads as minutes.
75
122
  const body = (answer.body ?? {});
76
123
  const expires = typeof body.expires_in === 'number' && body.expires_in > 0 ? body.expires_in : 60;
77
- return textResult('Resume link', { url: body.url, expires_in_seconds: expires });
124
+ return dataResult(`Resume link, valid for ${expires} seconds:`, { url: body.url, expires_in_seconds: expires });
78
125
  }));
79
126
  register('careers_decide', {
80
127
  title: 'Decide on an application',
81
128
  description: 'Super-admin only. Records the call on one application: `passed_on`, `archived`, or `reopen` to put it back at `screened`.',
82
- inputSchema: { id: z.string().uuid(), decision: z.enum(['passed_on', 'archived', 'reopen']) },
129
+ inputSchema: { id: APPLICATION_ID, decision: z.enum(['passed_on', 'archived', 'reopen']).describe('passed_on, archived, or reopen (back to screened).') },
83
130
  }, async (args) => withSession(deps, async (session) => {
84
131
  const answer = await callAgentDoor(session, deps.fetchImpl, 'POST', `/api/careers/applications/${encodeURIComponent(String(args.id))}/decide`, { decision: args.decision });
85
- return answer.ok ? textResult('Decision recorded', answer.body) : errorResult(doorFailureText('careers_decide', answer));
132
+ return answer.ok ? dataResult('Decision recorded', answer.body) : errorResult(doorFailureText('careers_decide', answer));
86
133
  }));
87
134
  register('careers_takehome_show', {
88
135
  title: 'Show a role take-home',
89
136
  description: 'Super-admin only. The subject, body, artifact link, reviewer rubric and pass score for one role. The rubric is reviewer material and never reaches a candidate.',
90
- inputSchema: { role: z.string().min(1).max(200) },
137
+ inputSchema: { role: z.string().min(1).max(200).describe('A role slug, e.g. fde-intern-2027 (the role_slug careers_list returns).') },
91
138
  }, async (args) => withSession(deps, async (session) => {
92
139
  const answer = await callAgentDoor(session, deps.fetchImpl, 'GET', `/api/careers/takehomes/${encodeURIComponent(String(args.role))}`);
93
- return answer.ok ? textResult('Take-home', answer.body) : errorResult(doorFailureText('careers_takehome_show', answer));
140
+ return answer.ok ? dataResult('Take-home', answer.body) : errorResult(doorFailureText('careers_takehome_show', answer));
94
141
  }));
95
142
  register('careers_takehome_set', {
96
143
  title: 'Edit a role take-home',
97
144
  description: 'Super-admin only. Edits one role\'s take-home. A body must keep the {first_name} and {link} placeholders. A role nobody seeded is refused rather than created.',
98
145
  inputSchema: {
99
- role: z.string().min(1).max(200),
100
- subject: z.string().min(1).max(998).optional(),
101
- body_text: z.string().min(1).max(100_000).optional(),
102
- artifact_link: z.string().url().max(2_000).optional(),
103
- rubric_md: z.string().min(1).max(100_000).optional(),
104
- pass_score: z.number().min(0).max(10).optional(),
146
+ role: z.string().min(1).max(200).describe('A role slug, e.g. fde-intern-2027.'),
147
+ subject: z.string().min(1).max(998).optional().describe('The invitation email subject.'),
148
+ body_text: z.string().min(1).max(100_000).optional().describe('The invitation body. Must keep the {first_name} and {link} placeholders.'),
149
+ artifact_link: z.string().url().max(2_000).optional().describe('A link to the take-home brief or starter repository.'),
150
+ rubric_md: z.string().min(1).max(100_000).optional().describe('The reviewer rubric, markdown. Never sent to a candidate.'),
151
+ pass_score: z.number().min(0).max(10).optional().describe('The grade (0-10) that passes.'),
105
152
  },
106
153
  }, async (args) => withSession(deps, async (session) => {
107
154
  const { role, ...patch } = args;
108
155
  const answer = await callAgentDoor(session, deps.fetchImpl, 'PUT', `/api/careers/takehomes/${encodeURIComponent(String(role))}`, patch);
109
- return answer.ok ? textResult('Take-home saved', answer.body) : errorResult(doorFailureText('careers_takehome_set', answer));
156
+ return answer.ok ? dataResult('Take-home saved', answer.body) : errorResult(doorFailureText('careers_takehome_set', answer));
110
157
  }));
111
158
  register('careers_settings_show', {
112
159
  title: 'Show the careers settings',
@@ -114,21 +161,21 @@ export function registerCareersTools(server, deps) {
114
161
  inputSchema: {},
115
162
  }, async () => withSession(deps, async (session) => {
116
163
  const answer = await callAgentDoor(session, deps.fetchImpl, 'GET', '/api/careers/settings');
117
- return answer.ok ? textResult('Careers settings', answer.body) : errorResult(doorFailureText('careers_settings_show', answer));
164
+ return answer.ok ? dataResult('Careers settings', answer.body) : errorResult(doorFailureText('careers_settings_show', answer));
118
165
  }));
119
166
  register('careers_settings_set', {
120
167
  title: 'Change the careers settings',
121
168
  description: 'Super-admin only. Writes any of the four careers settings. Each takes effect on the next screening, with no deploy.',
122
169
  inputSchema: {
123
- auto_invite_enabled: z.boolean().optional(),
124
- auto_invite_min_score: z.number().min(0).max(100).optional(),
125
- attention_min_score: z.number().min(0).max(100).optional(),
126
- invite_account_id: z.string().uuid().nullable().optional(),
127
- booking_link: z.string().url().max(2000).nullable().optional(),
128
- grader_model: z.string().min(3).max(200).optional(),
170
+ auto_invite_enabled: z.boolean().optional().describe('Send the take-home automatically to applicants over the auto-invite bar.'),
171
+ auto_invite_min_score: z.number().min(0).max(100).optional().describe('The screening score (0-100) at or over which the take-home goes out automatically.'),
172
+ attention_min_score: z.number().min(0).max(100).optional().describe('The screening score (0-100) at or over which an applicant is flagged for attention.'),
173
+ invite_account_id: z.string().uuid().nullable().optional().describe('The mailbox id (mail_accounts) invitations are sent from; null clears it.'),
174
+ booking_link: z.string().url().max(2000).nullable().optional().describe('The interview booking link sent after a passing grade; null clears it.'),
175
+ grader_model: z.string().min(3).max(200).optional().describe('The take-home grader model, provider:model (models_list ids).'),
129
176
  },
130
177
  }, async (args) => withSession(deps, async (session) => {
131
178
  const answer = await callAgentDoor(session, deps.fetchImpl, 'PUT', '/api/careers/settings', args);
132
- return answer.ok ? textResult('Careers settings saved', answer.body) : errorResult(doorFailureText('careers_settings_set', answer));
179
+ return answer.ok ? dataResult('Careers settings saved', answer.body) : errorResult(doorFailureText('careers_settings_set', answer));
133
180
  }));
134
181
  }