@bli-cockpit/mcp 0.1.61 → 0.1.63

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
@@ -103,7 +103,7 @@ about what an agent can reach.
103
103
 
104
104
  <!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->
105
105
 
106
- **107 of 112 Tower verbs have an MCP twin.**
106
+ **109 of 114 Tower verbs have an MCP twin.**
107
107
  Each tool goes through the SAME door its CLI verb calls, with the same
108
108
  collector device token — never a second route and never a service-role
109
109
  reader. `src/verb-census.test.ts` fails when a verb is in none of the
@@ -139,8 +139,10 @@ three tables below.
139
139
  | `cockpit careers takehome show` | `careers_takehome_show` | `GET /api/careers/takehomes/[role]` |
140
140
  | `cockpit correct` | `brief_correct` | `POST /api/jarvis/corrections` |
141
141
  | `cockpit docs create` | `docs_create` | `POST /api/docs/documents` |
142
+ | `cockpit docs link` | `docs_link` | `PUT /api/docs/documents/[id]/notion-link` |
142
143
  | `cockpit docs list` | `docs_list` | `GET /api/docs/documents` |
143
144
  | `cockpit docs read` | `docs_read` | `GET /api/docs/documents/[id]` |
145
+ | `cockpit docs sync-status` | `docs_sync_status` | `GET /api/docs/notion-sync` |
144
146
  | `cockpit docs tree` | `docs_tree` | `GET /api/docs/tree` |
145
147
  | `cockpit docs update` | `docs_update` | `PATCH /api/docs/documents/[id]` |
146
148
  | `cockpit issue comment` | `work_comment_issue` | `POST /api/work/issues/[id]/comments` |
@@ -382,6 +384,8 @@ narrowing who can read something.
382
384
  | `docs_read` | `GET /api/docs/documents/[id]` | One document's title and full body, by id or slug (resolved against `docs_list` first). |
383
385
  | `docs_create` | `POST /api/docs/documents` | Creates a document. Omitting `body_markdown` creates an empty one. |
384
386
  | `docs_update` | `PATCH /api/docs/documents/[id]` | Updates title/body/visibility/parent (a parent change IS a move). Emptying a document that holds text needs `allow_empty: true` (BLI-3757's guard). |
387
+ | `docs_link` | `PUT`/`DELETE /api/docs/documents/[id]/notion-link` | Ties a document to a Notion page for the two-way sync (`notion_page`: a URL or page id), or unties it with `clear: true`. The page becomes the truth: the next sync pulls it over the copy and keeps the copy as a version. |
388
+ | `docs_sync_status` | `GET /api/docs/notion-sync` | Every linked document's sync state (`synced` / `tower_changed` / `never_synced`), or one's by `id`. Tower's half only; metadata, never a body. |
385
389
  | `msg_channels` | `GET /api/msg/channels` | Every channel you are a member of. |
386
390
  | `msg_read` | `GET /api/msg/channels/[id]/messages` | Recent messages of one channel, newest first. `channel` is an id or a `#name`. |
387
391
  | `msg_thread` | `GET /api/msg/channels/[id]/messages?thread_parent_id=` | One thread's replies, oldest first, so it reads as a conversation. |
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Helpers behind the `docs_*` and `msg_*` tools (split out of
3
+ * `docs-msg-tools.ts` to keep it under the file-size ratchet): id resolution,
4
+ * list shaping and one-line renderers. No tool is registered here.
5
+ */
6
+ import { type ToolDeps } from "./tool-result.js";
7
+ /** A canonical uuid: a ref that already IS an id needs no list read to resolve. */
8
+ export declare const UUID_PATTERN: RegExp;
9
+ /** The door's own ceiling on `?limit=` (`/api/docs/documents` `DOCUMENT_LIST_MAX_LIMIT`). */
10
+ export declare const DOCS_LIST_MAX = 1000;
11
+ export type DocsMsgDeps = ToolDeps;
12
+ /** One node of `GET /api/docs/tree`. The door nests; nothing here re-nests. */
13
+ export interface DocTreeNode {
14
+ id: string;
15
+ title: string;
16
+ slug: string | null;
17
+ children: DocTreeNode[];
18
+ }
19
+ /**
20
+ * The tree as the text half prints it: id, title, slug, children. The door
21
+ * also sends visibility, source and a timestamp per node, which nothing here
22
+ * promised and which made the structured half 48 KB for 226 documents
23
+ * (BLI-10432). `docs_list` carries those fields for a flat read.
24
+ */
25
+ export declare function slimTree(nodes: DocTreeNode[]): DocTreeNode[];
26
+ /** Indented lines, deepest last — the same shape `cockpit docs tree` prints. */
27
+ export declare function renderDocTree(nodes: DocTreeNode[], depth: number): string;
28
+ export declare function countTree(nodes: DocTreeNode[]): number;
29
+ export interface DocRow {
30
+ id: string;
31
+ title: string;
32
+ slug: string | null;
33
+ visibility: string;
34
+ source?: string | null;
35
+ parent_id?: string | null;
36
+ updated_at: string;
37
+ /** BLI-3737: the body's length, the one fact about a body the LIST still carries. */
38
+ body_chars?: number;
39
+ }
40
+ /**
41
+ * The fields `docs_list` promises, and only those. The door's row also carries
42
+ * `created_by`, `updated_by`, `created_at`, `import_truncated` and
43
+ * `missing_block_count`: 105 KB for 226 documents before this (BLI-10432).
44
+ */
45
+ export declare function docListRow(row: DocRow): Record<string, unknown>;
46
+ export declare function docListLine(row: DocRow): string;
47
+ /**
48
+ * `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
49
+ * The list itself is metadata only — asking for a whole library used to cost
50
+ * every document's prose, so an agent that wants one folder or one phrase
51
+ * says so here and the database does the filtering.
52
+ */
53
+ export declare function docsListPath(args: {
54
+ parent_id?: unknown;
55
+ query?: unknown;
56
+ limit?: unknown;
57
+ }): string;
58
+ export declare function resolveDocumentId(deps: DocsMsgDeps, session: {
59
+ dashboardUrl: string;
60
+ deviceToken: string;
61
+ }, ref: string): Promise<{
62
+ status: "ok";
63
+ id: string;
64
+ } | {
65
+ status: "not_found";
66
+ } | {
67
+ status: "list_failed";
68
+ text: string;
69
+ }>;
70
+ export interface ChannelRow {
71
+ id: string;
72
+ name: string | null;
73
+ is_private: boolean;
74
+ is_dm: boolean;
75
+ }
76
+ export declare function resolveChannelId(deps: DocsMsgDeps, session: {
77
+ dashboardUrl: string;
78
+ deviceToken: string;
79
+ }, ref: string): Promise<{
80
+ status: "ok";
81
+ id: string;
82
+ } | {
83
+ status: "not_found";
84
+ } | {
85
+ status: "list_failed";
86
+ text: string;
87
+ }>;
88
+ /** One message as a line: id (msg_thread's `thread_parent_id`), time, author, text. */
89
+ export declare function messageLine(message: Record<string, unknown>): string;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Helpers behind the `docs_*` and `msg_*` tools (split out of
3
+ * `docs-msg-tools.ts` to keep it under the file-size ratchet): id resolution,
4
+ * list shaping and one-line renderers. No tool is registered here.
5
+ */
6
+ import { callAgentDoor } from "./agent-door.js";
7
+ import { DEFAULT_LIST_LIMIT, doorFailureText } from "./tool-result.js";
8
+ /** A canonical uuid: a ref that already IS an id needs no list read to resolve. */
9
+ export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
10
+ /** The door's own ceiling on `?limit=` (`/api/docs/documents` `DOCUMENT_LIST_MAX_LIMIT`). */
11
+ export const DOCS_LIST_MAX = 1000;
12
+ /**
13
+ * The tree as the text half prints it: id, title, slug, children. The door
14
+ * also sends visibility, source and a timestamp per node, which nothing here
15
+ * promised and which made the structured half 48 KB for 226 documents
16
+ * (BLI-10432). `docs_list` carries those fields for a flat read.
17
+ */
18
+ export function slimTree(nodes) {
19
+ return nodes.map((node) => ({
20
+ id: node.id,
21
+ title: node.title,
22
+ slug: node.slug ?? null,
23
+ children: slimTree(Array.isArray(node.children) ? node.children : []),
24
+ }));
25
+ }
26
+ /** Indented lines, deepest last — the same shape `cockpit docs tree` prints. */
27
+ export function renderDocTree(nodes, depth) {
28
+ return nodes
29
+ .map((node) => {
30
+ const line = `${" ".repeat(depth)}${node.title} (${node.slug ?? node.id})`;
31
+ const children = node.children?.length ? `\n${renderDocTree(node.children, depth + 1)}` : "";
32
+ return `${line}${children}`;
33
+ })
34
+ .join("\n");
35
+ }
36
+ export function countTree(nodes) {
37
+ return nodes.reduce((count, node) => count + 1 + countTree(node.children ?? []), 0);
38
+ }
39
+ /**
40
+ * The fields `docs_list` promises, and only those. The door's row also carries
41
+ * `created_by`, `updated_by`, `created_at`, `import_truncated` and
42
+ * `missing_block_count`: 105 KB for 226 documents before this (BLI-10432).
43
+ */
44
+ export function docListRow(row) {
45
+ return {
46
+ id: row.id,
47
+ slug: row.slug ?? null,
48
+ title: row.title,
49
+ visibility: row.visibility,
50
+ source: row.source ?? null,
51
+ parent_id: row.parent_id ?? null,
52
+ updated_at: row.updated_at,
53
+ body_chars: row.body_chars ?? null,
54
+ };
55
+ }
56
+ export function docListLine(row) {
57
+ return `${row.id} ${row.slug ?? "-"} ${row.title} [${row.visibility}${row.source ? `, ${row.source}` : ""}, `
58
+ + `${row.body_chars ?? "?"} chars, parent ${row.parent_id ?? "root"}, updated ${row.updated_at}]`;
59
+ }
60
+ /**
61
+ * `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
62
+ * The list itself is metadata only — asking for a whole library used to cost
63
+ * every document's prose, so an agent that wants one folder or one phrase
64
+ * says so here and the database does the filtering.
65
+ */
66
+ export function docsListPath(args) {
67
+ const params = new URLSearchParams();
68
+ if (typeof args.parent_id === "string" && args.parent_id.length > 0)
69
+ params.set("parent_id", args.parent_id);
70
+ if (typeof args.query === "string" && args.query.trim().length > 0)
71
+ params.set("query", args.query);
72
+ if (typeof args.limit === "number" && Number.isInteger(args.limit))
73
+ params.set("limit", String(args.limit));
74
+ // No limit named: one page plus one row, so "more exist" is read off the
75
+ // door instead of guessed from a full page (BLI-10432).
76
+ else if (args.limit === undefined)
77
+ params.set("limit", String(DEFAULT_LIST_LIMIT + 1));
78
+ const search = params.toString();
79
+ return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
80
+ }
81
+ export async function resolveDocumentId(deps, session, ref) {
82
+ // An id needs no lookup: the read itself answers 404 by name when it is
83
+ // wrong. Only a slug costs the whole-library list read (BLI-10432).
84
+ if (UUID_PATTERN.test(ref))
85
+ return { status: "ok", id: ref };
86
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
87
+ if (!response.ok)
88
+ return { status: "list_failed", text: doorFailureText("docs_list", response) };
89
+ const documents = (Array.isArray(response.body.documents) ? response.body.documents : []);
90
+ const match = documents.find((doc) => doc.id === ref || doc.slug === ref);
91
+ return match ? { status: "ok", id: match.id } : { status: "not_found" };
92
+ }
93
+ export async function resolveChannelId(deps, session, ref) {
94
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/msg/channels");
95
+ if (!response.ok)
96
+ return { status: "list_failed", text: doorFailureText("msg_channels", response) };
97
+ const channels = (Array.isArray(response.body.channels) ? response.body.channels : []);
98
+ const bare = ref.startsWith("#") ? ref.slice(1) : ref;
99
+ const match = channels.find((channel) => channel.id === ref || channel.name === bare);
100
+ return match ? { status: "ok", id: match.id } : { status: "not_found" };
101
+ }
102
+ /** One message as a line: id (msg_thread's `thread_parent_id`), time, author, text. */
103
+ export function messageLine(message) {
104
+ return `${String(message.id ?? "?")} ${String(message.created_at ?? "?")} `
105
+ + `${String(message.agent_label ?? message.user_id ?? "?")} ${String(message.content ?? "")}`;
106
+ }
@@ -9,19 +9,9 @@
9
9
  * on a machine with no collector pairing at all, so a missing session must
10
10
  * fail the ONE call that needed it, never the whole process.
11
11
  */
12
- import { type ToolDeps } from "./tool-result.js";
13
- export type DocsMsgDeps = ToolDeps;
14
- /**
15
- * `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
16
- * The list itself is metadata only — asking for a whole library used to cost
17
- * every document's prose, so an agent that wants one folder or one phrase
18
- * says so here and the database does the filtering.
19
- */
20
- export declare function docsListPath(args: {
21
- parent_id?: unknown;
22
- query?: unknown;
23
- limit?: unknown;
24
- }): string;
12
+ import { type DocsMsgDeps } from "./docs-msg-helpers.js";
13
+ export { docsListPath } from "./docs-msg-helpers.js";
14
+ export type { DocsMsgDeps } from "./docs-msg-helpers.js";
25
15
  export declare function registerDocsMsgTools(server: {
26
16
  registerTool: (...args: never[]) => unknown;
27
17
  }, deps: DocsMsgDeps): void;
@@ -12,105 +12,8 @@
12
12
  import { z } from "zod";
13
13
  import { callAgentDoor } from "./agent-door.js";
14
14
  import { DEFAULT_LIST_LIMIT, doorFailureText, errorResult, moreNote, registrarFor, textResult, withSession, } from "./tool-result.js";
15
- /** A canonical uuid: a ref that already IS an id needs no list read to resolve. */
16
- const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
17
- /** The door's own ceiling on `?limit=` (`/api/docs/documents` `DOCUMENT_LIST_MAX_LIMIT`). */
18
- const DOCS_LIST_MAX = 1000;
19
- /**
20
- * The tree as the text half prints it: id, title, slug, children. The door
21
- * also sends visibility, source and a timestamp per node, which nothing here
22
- * promised and which made the structured half 48 KB for 226 documents
23
- * (BLI-10432). `docs_list` carries those fields for a flat read.
24
- */
25
- function slimTree(nodes) {
26
- return nodes.map((node) => ({
27
- id: node.id,
28
- title: node.title,
29
- slug: node.slug ?? null,
30
- children: slimTree(Array.isArray(node.children) ? node.children : []),
31
- }));
32
- }
33
- /** Indented lines, deepest last — the same shape `cockpit docs tree` prints. */
34
- function renderDocTree(nodes, depth) {
35
- return nodes
36
- .map((node) => {
37
- const line = `${" ".repeat(depth)}${node.title} (${node.slug ?? node.id})`;
38
- const children = node.children?.length ? `\n${renderDocTree(node.children, depth + 1)}` : "";
39
- return `${line}${children}`;
40
- })
41
- .join("\n");
42
- }
43
- function countTree(nodes) {
44
- return nodes.reduce((count, node) => count + 1 + countTree(node.children ?? []), 0);
45
- }
46
- /**
47
- * The fields `docs_list` promises, and only those. The door's row also carries
48
- * `created_by`, `updated_by`, `created_at`, `import_truncated` and
49
- * `missing_block_count`: 105 KB for 226 documents before this (BLI-10432).
50
- */
51
- function docListRow(row) {
52
- return {
53
- id: row.id,
54
- slug: row.slug ?? null,
55
- title: row.title,
56
- visibility: row.visibility,
57
- source: row.source ?? null,
58
- parent_id: row.parent_id ?? null,
59
- updated_at: row.updated_at,
60
- body_chars: row.body_chars ?? null,
61
- };
62
- }
63
- function docListLine(row) {
64
- return `${row.id} ${row.slug ?? "-"} ${row.title} [${row.visibility}${row.source ? `, ${row.source}` : ""}, `
65
- + `${row.body_chars ?? "?"} chars, parent ${row.parent_id ?? "root"}, updated ${row.updated_at}]`;
66
- }
67
- /**
68
- * `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
69
- * The list itself is metadata only — asking for a whole library used to cost
70
- * every document's prose, so an agent that wants one folder or one phrase
71
- * says so here and the database does the filtering.
72
- */
73
- export function docsListPath(args) {
74
- const params = new URLSearchParams();
75
- if (typeof args.parent_id === "string" && args.parent_id.length > 0)
76
- params.set("parent_id", args.parent_id);
77
- if (typeof args.query === "string" && args.query.trim().length > 0)
78
- params.set("query", args.query);
79
- if (typeof args.limit === "number" && Number.isInteger(args.limit))
80
- params.set("limit", String(args.limit));
81
- // No limit named: one page plus one row, so "more exist" is read off the
82
- // door instead of guessed from a full page (BLI-10432).
83
- else if (args.limit === undefined)
84
- params.set("limit", String(DEFAULT_LIST_LIMIT + 1));
85
- const search = params.toString();
86
- return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
87
- }
88
- async function resolveDocumentId(deps, session, ref) {
89
- // An id needs no lookup: the read itself answers 404 by name when it is
90
- // wrong. Only a slug costs the whole-library list read (BLI-10432).
91
- if (UUID_PATTERN.test(ref))
92
- return { status: "ok", id: ref };
93
- const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
94
- if (!response.ok)
95
- return { status: "list_failed", text: doorFailureText("docs_list", response) };
96
- const documents = (Array.isArray(response.body.documents) ? response.body.documents : []);
97
- const match = documents.find((doc) => doc.id === ref || doc.slug === ref);
98
- return match ? { status: "ok", id: match.id } : { status: "not_found" };
99
- }
100
- async function resolveChannelId(deps, session, ref) {
101
- const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/msg/channels");
102
- if (!response.ok)
103
- return { status: "list_failed", text: doorFailureText("msg_channels", response) };
104
- const channels = (Array.isArray(response.body.channels) ? response.body.channels : []);
105
- const bare = ref.startsWith("#") ? ref.slice(1) : ref;
106
- const match = channels.find((channel) => channel.id === ref || channel.name === bare);
107
- return match ? { status: "ok", id: match.id } : { status: "not_found" };
108
- }
109
- /** One message as a line: id (msg_thread's `thread_parent_id`), time, author, text. */
110
- function messageLine(message) {
111
- return `${String(message.id ?? "?")} ${String(message.created_at ?? "?")} `
112
- + `${String(message.agent_label ?? message.user_id ?? "?")} ${String(message.content ?? "")}`;
113
- }
15
+ import { DOCS_LIST_MAX, slimTree, renderDocTree, countTree, docListRow, docListLine, resolveDocumentId, resolveChannelId, messageLine, docsListPath } from "./docs-msg-helpers.js";
16
+ export { docsListPath } from "./docs-msg-helpers.js";
114
17
  export function registerDocsMsgTools(server, deps) {
115
18
  const register = registrarFor(server);
116
19
  register("docs_list", {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The Notion half of the `docs_*` tools (BLI-10430) — the same door
3
+ * `cockpit docs link` calls, over this machine's device token. A sibling of
4
+ * `docs-msg-tools.ts` rather than more of it, the same split the collector
5
+ * makes (`commands/docs-notion.ts`).
6
+ *
7
+ * `docs_link` → `PUT /api/docs/documents/[id]/notion-link` (or `DELETE` with
8
+ * `clear: true`). A link makes the Notion page the truth: the next sync pulls
9
+ * it over the Tower copy and keeps the copy as a version.
10
+ * `docs_sync_status` → `GET /api/docs/notion-sync[?document_id=]`: Tower's half
11
+ * of the sync state, metadata only.
12
+ */
13
+ import { type DocsMsgDeps } from "./docs-msg-helpers.js";
14
+ export declare function registerDocsNotionTools(server: {
15
+ registerTool: (...args: never[]) => unknown;
16
+ }, deps: DocsMsgDeps): void;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The Notion half of the `docs_*` tools (BLI-10430) — the same door
3
+ * `cockpit docs link` calls, over this machine's device token. A sibling of
4
+ * `docs-msg-tools.ts` rather than more of it, the same split the collector
5
+ * makes (`commands/docs-notion.ts`).
6
+ *
7
+ * `docs_link` → `PUT /api/docs/documents/[id]/notion-link` (or `DELETE` with
8
+ * `clear: true`). A link makes the Notion page the truth: the next sync pulls
9
+ * it over the Tower copy and keeps the copy as a version.
10
+ * `docs_sync_status` → `GET /api/docs/notion-sync[?document_id=]`: Tower's half
11
+ * of the sync state, metadata only.
12
+ */
13
+ import { z } from "zod";
14
+ import { callAgentDoor } from "./agent-door.js";
15
+ import { resolveDocumentId } from "./docs-msg-helpers.js";
16
+ import { doorFailureText, errorResult, registrarFor, textResult, withSession } from "./tool-result.js";
17
+ export function registerDocsNotionTools(server, deps) {
18
+ const register = registrarFor(server);
19
+ register("docs_link", {
20
+ title: "Link a Tower document to a Notion page",
21
+ description: "Ties a document to a Notion page so the two-way sync moves it both ways, or unties it with clear: true. "
22
+ + "The page becomes the truth: the next sync pulls it over this copy and keeps the copy as a version. "
23
+ + "Refusals keep Tower's reason: invalid_notion_page, notion_page_already_linked, document_not_writable.",
24
+ inputSchema: {
25
+ id: z.string().min(1).max(200).describe("A document id, or its slug."),
26
+ notion_page: z
27
+ .string()
28
+ .min(1)
29
+ .max(500)
30
+ .optional()
31
+ .describe("The Notion page URL (app.notion.com/p/… or notion.so/…) or its page id."),
32
+ clear: z.boolean().optional().describe("true to untie the document from Notion instead."),
33
+ },
34
+ }, async (args) => withSession(deps, async (session) => {
35
+ const clear = args.clear === true;
36
+ const notionPage = typeof args.notion_page === "string" ? args.notion_page : undefined;
37
+ if (clear === Boolean(notionPage)) {
38
+ return errorResult("docs_link needs exactly one of notion_page (to link) or clear: true (to unlink).");
39
+ }
40
+ const ref = String(args.id ?? "");
41
+ const resolved = await resolveDocumentId(deps, session, ref);
42
+ if (resolved.status === "list_failed")
43
+ return errorResult(resolved.text);
44
+ if (resolved.status === "not_found")
45
+ return errorResult(`No document matches "${ref}" — check docs_list for the id or slug.`);
46
+ const path = `/api/docs/documents/${encodeURIComponent(resolved.id)}/notion-link`;
47
+ const response = clear
48
+ ? await callAgentDoor(session, deps.fetchImpl, "DELETE", path)
49
+ : await callAgentDoor(session, deps.fetchImpl, "PUT", path, { notion_page: notionPage });
50
+ if (!response.ok)
51
+ return errorResult(doorFailureText("docs_link", response));
52
+ const outcome = String(response.body.outcome ?? "");
53
+ const document = response.body.document;
54
+ return textResult(`${outcome || "done"}: "${document?.title ?? ""}" (${document?.id ?? resolved.id}).`, {
55
+ outcome,
56
+ document,
57
+ });
58
+ }));
59
+ register("docs_sync_status", {
60
+ title: "Read the Tower-Notion sync status",
61
+ description: "Every document linked to a Notion page (or one, by id or slug) with its sync state: synced (the body is what "
62
+ + "both sides last agreed on), tower_changed (edited in Tower since; the next write-back pushes it), never_synced "
63
+ + "(linked, no baseline yet). Tower's half only: a Notion edit shows up at the next pull. Never a body.",
64
+ inputSchema: {
65
+ id: z.string().min(1).max(200).optional().describe("A document id or slug, for one document's status."),
66
+ },
67
+ }, async (args) => withSession(deps, async (session) => {
68
+ let path = "/api/docs/notion-sync";
69
+ if (typeof args.id === "string" && args.id.length > 0) {
70
+ const resolved = await resolveDocumentId(deps, session, args.id);
71
+ if (resolved.status === "list_failed")
72
+ return errorResult(resolved.text);
73
+ if (resolved.status === "not_found")
74
+ return errorResult(`No document matches "${args.id}" — check docs_list for the id or slug.`);
75
+ path = `${path}?document_id=${encodeURIComponent(resolved.id)}`;
76
+ }
77
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", path);
78
+ if (!response.ok)
79
+ return errorResult(doorFailureText("docs_sync_status", response));
80
+ const counts = response.body.counts;
81
+ const summary = Object.entries(counts?.by_state ?? {})
82
+ .map(([state, count]) => `${count} ${state}`)
83
+ .join(", ");
84
+ return textResult(`${counts?.linked ?? 0} linked: ${summary}.`, {
85
+ counts,
86
+ documents: response.body.documents ?? [],
87
+ });
88
+ }));
89
+ }
package/dist/server.js CHANGED
@@ -10,6 +10,7 @@ import { z } from "zod";
10
10
  import { registerBriefTools } from "./brief-tools.js";
11
11
  import { registerBriefWriteTools } from "./brief-write-tools.js";
12
12
  import { registerDocsMsgTools } from "./docs-msg-tools.js";
13
+ import { registerDocsNotionTools } from "./docs-notion-tools.js";
13
14
  import { registerJarvisTools } from "./jarvis-tools.js";
14
15
  import { registerCalTools } from "./cal-tools.js";
15
16
  import { registerMailTools } from "./mail-tools.js";
@@ -415,6 +416,8 @@ export function createServer(deps) {
415
416
  // no `cockpit login` pairing yet still serves the three event tools; only a
416
417
  // docs/msg call on that machine fails, by name.
417
418
  registerDocsMsgTools(server, { fetchImpl: deps.fetchImpl });
419
+ // BLI-10430: docs_link, the Notion half of the docs tools, on the same path.
420
+ registerDocsNotionTools(server, { fetchImpl: deps.fetchImpl });
418
421
  // BLI-3716: `work_*` — the issue tracker, on the same device-token path as
419
422
  // the docs/msg tools above, for the same reason: a coding session should
420
423
  // file and move a Tower issue the way it files and moves a Linear one.
@@ -10,7 +10,7 @@
10
10
  * from `src/` under Node's type stripping, so it may import nothing.
11
11
  */
12
12
  /** Tools that change nothing. The smoke calls every one. */
13
- export declare const READ_TOOLS: readonly ["get_ticket_timeline", "get_active_tickets", "docs_list", "docs_tree", "docs_read", "msg_channels", "msg_read", "msg_thread", "work_list_issues", "work_get_issue", "work_issue_history", "work_list_projects", "careers_list", "careers_show", "careers_resume_url", "careers_takehome_show", "careers_settings_show", "usage_people", "usage_sessions", "mail_accounts", "mail_inbox", "mail_read", "mail_search", "mail_attachment", "cal_today", "cal_week", "cal_next", "cal_find", "cal_calendars", "jarvis_ask", "jarvis_trace", "jarvis_check", "models_list", "models_card", "models_compare", "brief_read", "brief_history", "brief_status", "notes_folders", "notes_list", "notes_show", "notes_shelf", "notes_shelves", "notes_place", "notes_accounts", "ops_status", "slack_coverage", "slack_read", "memory_profile", "memory_consolidation", "settings_show", "settings_list", "model_show", "team_members", "team_device_list", "scout_board", "workbook_read", "tower_search"];
13
+ export declare const READ_TOOLS: readonly ["get_ticket_timeline", "get_active_tickets", "docs_list", "docs_tree", "docs_read", "docs_sync_status", "msg_channels", "msg_read", "msg_thread", "work_list_issues", "work_get_issue", "work_issue_history", "work_list_projects", "careers_list", "careers_show", "careers_resume_url", "careers_takehome_show", "careers_settings_show", "usage_people", "usage_sessions", "mail_accounts", "mail_inbox", "mail_read", "mail_search", "mail_attachment", "cal_today", "cal_week", "cal_next", "cal_find", "cal_calendars", "jarvis_ask", "jarvis_trace", "jarvis_check", "models_list", "models_card", "models_compare", "brief_read", "brief_history", "brief_status", "notes_folders", "notes_list", "notes_show", "notes_shelf", "notes_shelves", "notes_place", "notes_accounts", "ops_status", "slack_coverage", "slack_read", "memory_profile", "memory_consolidation", "settings_show", "settings_list", "model_show", "team_members", "team_device_list", "scout_board", "workbook_read", "tower_search"];
14
14
  /**
15
15
  * Tools that write, but answer a dry run that writes nothing. The smoke calls
16
16
  * each one ONLY with its dry-run flag set (`scripts/smoke.ts` says which).
@@ -21,7 +21,7 @@ export declare const DRY_RUN_TOOLS: readonly ["ops_recompile", "memory_requeue"]
21
21
  * candidates, share calendars. Never called by the smoke; covered by the unit
22
22
  * suites (`write-tools.test.ts` and each family's own).
23
23
  */
24
- export declare const WRITE_TOOLS: readonly ["emit_event", "docs_create", "docs_update", "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_takehome_set", "careers_settings_set", "mail_send", "mail_sync", "mail_detach", "cal_create", "cal_share", "cal_sync", "cal_detach", "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_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_takehome_set", "careers_settings_set", "mail_send", "mail_sync", "mail_detach", "cal_create", "cal_share", "cal_sync", "cal_detach", "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
  /**
@@ -16,6 +16,7 @@ export const READ_TOOLS = [
16
16
  "docs_list",
17
17
  "docs_tree",
18
18
  "docs_read",
19
+ "docs_sync_status",
19
20
  "msg_channels",
20
21
  "msg_read",
21
22
  "msg_thread",
@@ -84,6 +85,7 @@ export const WRITE_TOOLS = [
84
85
  "emit_event",
85
86
  "docs_create",
86
87
  "docs_update",
88
+ "docs_link",
87
89
  "msg_create_channel",
88
90
  "msg_dm",
89
91
  "msg_send",
@@ -234,6 +234,8 @@ export const MCP_TWINS = {
234
234
  "docs read": { tool: "docs_read", door: "GET /api/docs/documents/[id]" },
235
235
  "docs create": { tool: "docs_create", door: "POST /api/docs/documents" },
236
236
  "docs update": { tool: "docs_update", door: "PATCH /api/docs/documents/[id]" },
237
+ "docs link": { tool: "docs_link", door: "PUT /api/docs/documents/[id]/notion-link" },
238
+ "docs sync-status": { tool: "docs_sync_status", door: "GET /api/docs/notion-sync" },
237
239
  "msg channels": { tool: "msg_channels", door: "GET /api/msg/channels" },
238
240
  "msg create": { tool: "msg_create_channel", door: "POST /api/msg/channels" },
239
241
  "msg dm": { tool: "msg_dm", door: "POST /api/msg/channels (dm)" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.61",
3
+ "version": "0.1.63",
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",