@bli-cockpit/mcp 0.1.0 → 0.1.1

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/dist/server.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { z } from "zod";
9
9
  import { registerDocsMsgTools } from "./docs-msg-tools.js";
10
+ import { registerWorkTools } from "./work-tools.js";
10
11
  export const PACKAGE_NAME = "@bli-cockpit/mcp";
11
12
  export const PACKAGE_VERSION = "0.1.0";
12
13
  // ---- input schemas (Zod raw shapes) -----------------------------------------
@@ -370,5 +371,9 @@ export function createServer(deps) {
370
371
  // no `cockpit login` pairing yet still serves the three event tools; only a
371
372
  // docs/msg call on that machine fails, by name.
372
373
  registerDocsMsgTools(server, { fetchImpl: deps.fetchImpl });
374
+ // BLI-3716: `work_*` — the issue tracker, on the same device-token path as
375
+ // the docs/msg tools above, for the same reason: a coding session should
376
+ // file and move a Tower issue the way it files and moves a Linear one.
377
+ registerWorkTools(server, { fetchImpl: deps.fetchImpl });
373
378
  return server;
374
379
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `work_*` MCP tools (BLI-3716) — Tower's issue tracker on the `bli-tower`
3
+ * server, over the same `/api/work/**` doors `cockpit issue` and the browser
4
+ * call, authenticated with this machine's collector device token.
5
+ *
6
+ * The point of this file, in one sentence: a Claude Code or Codex session
7
+ * should be able to file and move a TOWER issue exactly the way it files and
8
+ * moves a Linear one — no browser, no uuid archaeology, no second vocabulary.
9
+ * So every tool takes `BLI-####` wherever an issue is named (the SERVER
10
+ * resolves it, `lib/work/issue-ref.ts`), and `work_create_issue` answers with
11
+ * the identifier it minted.
12
+ *
13
+ * Same session discipline as `docs-msg-tools.ts`: the session is loaded fresh
14
+ * per call, so a machine with no `cockpit login` pairing still serves this
15
+ * server's other tools and only a `work_` call fails, by name.
16
+ */
17
+ import { loadAgentDoorSession } from "./agent-door-session.js";
18
+ import { type FetchImpl } from "./agent-door.js";
19
+ export interface WorkDeps {
20
+ fetchImpl: FetchImpl;
21
+ /** Injectable for tests; defaults to reading `~/.config/bli-cockpit/session.json`. */
22
+ loadSession?: typeof loadAgentDoorSession;
23
+ }
24
+ /** The closed state vocabulary, verbatim from `lib/work/api-doors.ts` `WORK_ISSUE_STATES`. */
25
+ export declare const WORK_ISSUE_STATES: readonly ["backlog", "todo", "in_progress", "in_review", "done", "canceled"];
26
+ export declare function registerWorkTools(server: {
27
+ registerTool: (...args: never[]) => unknown;
28
+ }, deps: WorkDeps): void;
@@ -0,0 +1,233 @@
1
+ /**
2
+ * `work_*` MCP tools (BLI-3716) — Tower's issue tracker on the `bli-tower`
3
+ * server, over the same `/api/work/**` doors `cockpit issue` and the browser
4
+ * call, authenticated with this machine's collector device token.
5
+ *
6
+ * The point of this file, in one sentence: a Claude Code or Codex session
7
+ * should be able to file and move a TOWER issue exactly the way it files and
8
+ * moves a Linear one — no browser, no uuid archaeology, no second vocabulary.
9
+ * So every tool takes `BLI-####` wherever an issue is named (the SERVER
10
+ * resolves it, `lib/work/issue-ref.ts`), and `work_create_issue` answers with
11
+ * the identifier it minted.
12
+ *
13
+ * Same session discipline as `docs-msg-tools.ts`: the session is loaded fresh
14
+ * per call, so a machine with no `cockpit login` pairing still serves this
15
+ * server's other tools and only a `work_` call fails, by name.
16
+ */
17
+ import { z } from "zod";
18
+ import { loadAgentDoorSession } from "./agent-door-session.js";
19
+ import { callAgentDoor } from "./agent-door.js";
20
+ /** The closed state vocabulary, verbatim from `lib/work/api-doors.ts` `WORK_ISSUE_STATES`. */
21
+ export const WORK_ISSUE_STATES = [
22
+ "backlog",
23
+ "todo",
24
+ "in_progress",
25
+ "in_review",
26
+ "done",
27
+ "canceled",
28
+ ];
29
+ function textResult(text, structured) {
30
+ return { content: [{ type: "text", text }], ...(structured ? { structuredContent: structured } : {}) };
31
+ }
32
+ function errorResult(text) {
33
+ return { isError: true, content: [{ type: "text", text }] };
34
+ }
35
+ async function withSession(deps, run) {
36
+ const loadSession = deps.loadSession ?? loadAgentDoorSession;
37
+ const loaded = loadSession();
38
+ if (!loaded.ok)
39
+ return errorResult(`This machine is not paired with Tower (${loaded.reason}). ${loaded.message}`);
40
+ return run(loaded.session);
41
+ }
42
+ function doorFailureText(door, response) {
43
+ if (response.transportError) {
44
+ return `Tower could not be reached for ${door} (${response.transportError}). Nothing was read or written.`;
45
+ }
46
+ const reason = typeof response.body.reason === "string"
47
+ ? response.body.reason
48
+ : typeof response.body.error === "string"
49
+ ? response.body.error
50
+ : "unknown_error";
51
+ const message = typeof response.body.message === "string" ? response.body.message : `Tower answered ${response.status}.`;
52
+ return `Tower refused ${door} (${reason}): ${message}`;
53
+ }
54
+ /** A project id from a project NAME or a uuid — exact after case-folding, never fuzzy. */
55
+ async function resolveProjectId(deps, session, ref) {
56
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/work/projects?include_archived=true");
57
+ if (!response.ok)
58
+ return { status: "list_failed", text: doorFailureText("work_list_projects", response) };
59
+ const projects = (Array.isArray(response.body.projects) ? response.body.projects : []);
60
+ const wanted = ref.toLowerCase();
61
+ const match = projects.find((project) => project.id === ref || project.name.toLowerCase() === wanted);
62
+ return match ? { status: "ok", id: match.id } : { status: "not_found" };
63
+ }
64
+ const ISSUE_REF = z
65
+ .string()
66
+ .min(1)
67
+ .max(200)
68
+ .describe("A BLI-#### identifier (e.g. BLI-3654) or an issue's uuid. Both work.");
69
+ export function registerWorkTools(server, deps) {
70
+ const register = server.registerTool.bind(server);
71
+ register("work_list_issues", {
72
+ title: "List Tower issues",
73
+ description: "Issues you may see, most recently updated first. Filter by state, assignee (\"me\", \"unassigned\", or a uuid) "
74
+ + "or project (name or uuid). Never a description — call work_get_issue for one issue's full text.",
75
+ inputSchema: {
76
+ state: z.enum(WORK_ISSUE_STATES).optional(),
77
+ assignee: z.string().min(1).max(100).optional().describe('"me", "unassigned", or a person\'s uuid.'),
78
+ project: z.string().min(1).max(200).optional().describe("A project name or uuid."),
79
+ limit: z.number().int().min(1).max(1000).optional(),
80
+ },
81
+ }, async (args) => withSession(deps, async (session) => {
82
+ const query = new URLSearchParams();
83
+ if (args.state)
84
+ query.set("state", String(args.state));
85
+ if (args.assignee)
86
+ query.set("assignee_id", String(args.assignee));
87
+ if (args.limit)
88
+ query.set("limit", String(args.limit));
89
+ if (args.project) {
90
+ const resolved = await resolveProjectId(deps, session, String(args.project));
91
+ if (resolved.status === "list_failed")
92
+ return errorResult(resolved.text);
93
+ if (resolved.status === "not_found") {
94
+ return errorResult(`No project matches "${String(args.project)}" — call work_list_projects for the names.`);
95
+ }
96
+ query.set("project_id", resolved.id);
97
+ }
98
+ const suffix = query.toString();
99
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/issues${suffix ? `?${suffix}` : ""}`);
100
+ if (!response.ok)
101
+ return errorResult(doorFailureText("work_list_issues", response));
102
+ const issues = (Array.isArray(response.body.issues) ? response.body.issues : []);
103
+ const lines = issues.map((issue) => `${issue.identifier} ${issue.state} ${issue.title}`).join("\n");
104
+ return textResult(`${issues.length} issue(s).${lines ? `\n${lines}` : ""}`, { issues });
105
+ }));
106
+ register("work_get_issue", {
107
+ title: "Read a Tower issue",
108
+ description: "One issue's title, state, priority, assignee, description and every comment on it.",
109
+ inputSchema: { id: ISSUE_REF },
110
+ }, async (args) => withSession(deps, async (session) => {
111
+ const ref = String(args.id ?? "");
112
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/issues/${encodeURIComponent(ref)}`);
113
+ if (!response.ok)
114
+ return errorResult(doorFailureText("work_get_issue", response));
115
+ const issue = response.body.issue;
116
+ // A comment read that fails does not lose the issue already in hand.
117
+ const commentsResponse = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/issues/${encodeURIComponent(issue?.id ?? ref)}/comments`);
118
+ const comments = commentsResponse.ok ? (commentsResponse.body.comments ?? []) : [];
119
+ const commentText = comments
120
+ .map((comment) => `[${comment.created_at ?? ""}] ${comment.body_markdown ?? ""}`)
121
+ .join("\n\n");
122
+ return textResult(`${issue?.identifier ?? ref} ${issue?.title ?? ""} (${issue?.state ?? "?"})\n\n${issue?.description ?? "(no description)"}`
123
+ + (commentText ? `\n\n--- comments ---\n${commentText}` : "")
124
+ + (commentsResponse.ok ? "" : "\n\n(comments could not be read on this call)"), { issue, comments, ...(commentsResponse.ok ? {} : { comments_unread: true }) });
125
+ }));
126
+ register("work_create_issue", {
127
+ title: "File a Tower issue",
128
+ description: "Files a new issue and returns the BLI-#### identifier it was given. Use this the way you would file a Linear ticket.",
129
+ inputSchema: {
130
+ title: z.string().min(1).max(300),
131
+ description: z.string().max(200_000).optional(),
132
+ project: z.string().min(1).max(200).optional().describe("A project name or uuid."),
133
+ priority: z.number().int().min(0).max(4).optional().describe("0 none, 1 urgent, 2 high, 3 medium, 4 low."),
134
+ assignee: z.string().min(1).max(100).optional().describe('"me" or a person\'s uuid.'),
135
+ },
136
+ }, async (args) => withSession(deps, async (session) => {
137
+ let projectId;
138
+ if (args.project) {
139
+ const resolved = await resolveProjectId(deps, session, String(args.project));
140
+ if (resolved.status === "list_failed")
141
+ return errorResult(resolved.text);
142
+ if (resolved.status === "not_found") {
143
+ return errorResult(`No project matches "${String(args.project)}" — call work_list_projects for the names.`);
144
+ }
145
+ projectId = resolved.id;
146
+ }
147
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/work/issues", {
148
+ title: args.title,
149
+ ...(args.description === undefined ? {} : { description: args.description }),
150
+ ...(projectId ? { project_id: projectId } : {}),
151
+ ...(args.priority === undefined ? {} : { priority: args.priority }),
152
+ ...(args.assignee ? { assignee_id: args.assignee } : {}),
153
+ });
154
+ if (!response.ok)
155
+ return errorResult(doorFailureText("work_create_issue", response));
156
+ const issue = response.body.issue;
157
+ return textResult(`Filed ${issue?.identifier ?? ""} — ${issue?.title ?? ""} (${issue?.id ?? ""}).`, { issue });
158
+ }));
159
+ register("work_update_issue", {
160
+ title: "Update a Tower issue",
161
+ description: "Changes an issue's title, description, priority, assignee or project. Only the fields you pass change. "
162
+ + "State does NOT move here — call work_move_issue, which records the move.",
163
+ inputSchema: {
164
+ id: ISSUE_REF,
165
+ title: z.string().min(1).max(300).optional(),
166
+ description: z.string().max(200_000).nullable().optional(),
167
+ priority: z.number().int().min(0).max(4).optional(),
168
+ assignee: z.string().min(1).max(100).nullable().optional().describe('"me", a uuid, or null to unassign.'),
169
+ project: z.string().min(1).max(200).optional().describe("A project name or uuid."),
170
+ },
171
+ }, async (args) => withSession(deps, async (session) => {
172
+ const ref = String(args.id ?? "");
173
+ let projectId;
174
+ if (args.project) {
175
+ const resolved = await resolveProjectId(deps, session, String(args.project));
176
+ if (resolved.status === "list_failed")
177
+ return errorResult(resolved.text);
178
+ if (resolved.status === "not_found") {
179
+ return errorResult(`No project matches "${String(args.project)}" — call work_list_projects for the names.`);
180
+ }
181
+ projectId = resolved.id;
182
+ }
183
+ const response = await callAgentDoor(session, deps.fetchImpl, "PATCH", `/api/work/issues/${encodeURIComponent(ref)}`, {
184
+ ...(args.title !== undefined ? { title: args.title } : {}),
185
+ ...(args.description !== undefined ? { description: args.description } : {}),
186
+ ...(args.priority !== undefined ? { priority: args.priority } : {}),
187
+ ...(args.assignee !== undefined ? { assignee_id: args.assignee } : {}),
188
+ ...(projectId ? { project_id: projectId } : {}),
189
+ });
190
+ if (!response.ok)
191
+ return errorResult(doorFailureText("work_update_issue", response));
192
+ const issue = response.body.issue;
193
+ return textResult(`Updated ${issue?.identifier ?? ref} — ${issue?.title ?? ""}.`, { issue });
194
+ }));
195
+ register("work_move_issue", {
196
+ title: "Move a Tower issue",
197
+ description: "Moves an issue to a new state and records the move in its history. This is how an issue's state changes; "
198
+ + "there is no other door.",
199
+ inputSchema: { id: ISSUE_REF, state: z.enum(WORK_ISSUE_STATES) },
200
+ }, async (args) => withSession(deps, async (session) => {
201
+ const ref = String(args.id ?? "");
202
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", `/api/work/issues/${encodeURIComponent(ref)}/state`, {
203
+ state: args.state,
204
+ });
205
+ if (!response.ok)
206
+ return errorResult(doorFailureText("work_move_issue", response));
207
+ const issue = response.body.issue;
208
+ return textResult(`${issue?.identifier ?? ref} is now ${issue?.state ?? String(args.state)}.`, { issue });
209
+ }));
210
+ register("work_comment_issue", {
211
+ title: "Comment on a Tower issue",
212
+ description: "Posts a comment. Comments are append-only — nothing here edits or deletes one.",
213
+ inputSchema: { id: ISSUE_REF, body_markdown: z.string().min(1).max(50_000) },
214
+ }, async (args) => withSession(deps, async (session) => {
215
+ const ref = String(args.id ?? "");
216
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", `/api/work/issues/${encodeURIComponent(ref)}/comments`, { body_markdown: args.body_markdown });
217
+ if (!response.ok)
218
+ return errorResult(doorFailureText("work_comment_issue", response));
219
+ const comment = response.body.comment;
220
+ return textResult(`Commented on ${ref} (${comment?.id ?? "?"}).`, { comment });
221
+ }));
222
+ register("work_list_projects", {
223
+ title: "List Tower projects",
224
+ description: "The projects issues are filed under. Use a project's name with work_list_issues or work_create_issue.",
225
+ inputSchema: { include_archived: z.boolean().optional() },
226
+ }, async (args) => withSession(deps, async (session) => {
227
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/projects${args.include_archived ? "?include_archived=true" : ""}`);
228
+ if (!response.ok)
229
+ return errorResult(doorFailureText("work_list_projects", response));
230
+ const projects = (Array.isArray(response.body.projects) ? response.body.projects : []);
231
+ return textResult(`${projects.length} project(s).${projects.length ? `\n${projects.map((p) => `${p.name} (${p.id})`).join("\n")}` : ""}`, { projects });
232
+ }));
233
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "private": false,
5
5
  "description": "bli-tower — an MCP server over BLI Cockpit's docs/msg agent doors (docs_*, msg_*), plus the legacy event-stream tools (emit_event, get_ticket_timeline, get_active_tickets).",
6
6
  "type": "module",