vessels-mcp 0.2.0

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.
Files changed (2) hide show
  1. package/dist/index.js +309 -0
  2. package/package.json +34 -0
package/dist/index.js ADDED
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { readFileSync } from "fs";
5
+ import { homedir } from "os";
6
+ import { join } from "path";
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { z } from "zod";
10
+ import { Vessels } from "vessels-sdk";
11
+ function resolveKey() {
12
+ const env = process.env.VESSELS_API_KEY;
13
+ if (env && !env.startsWith("${")) return env;
14
+ try {
15
+ const k = readFileSync(join(homedir(), ".thru-do-vessels-key"), "utf8").trim();
16
+ if (k) return k;
17
+ } catch {
18
+ }
19
+ return void 0;
20
+ }
21
+ var apiKey = resolveKey();
22
+ var defaultVessel = process.env.VESSELS_VESSEL ?? "claude-code";
23
+ var vesselTitle = process.env.VESSELS_VESSEL_TITLE ?? "Claude Code";
24
+ var baseUrl = process.env.VESSELS_BASE_URL ?? "https://vessels.app";
25
+ if (!apiKey) {
26
+ console.error("[vessels-mcp] no VESSELS_API_KEY (env or ~/.thru-do-vessels-key)");
27
+ process.exit(1);
28
+ }
29
+ var v = new Vessels({ apiKey, baseUrl });
30
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
+ var ok = (text) => ({ content: [{ type: "text", text }] });
32
+ async function drain() {
33
+ for (; ; ) {
34
+ const r = await v.poll({ ack: true, limit: 100 });
35
+ if (!r.hasMore) return;
36
+ }
37
+ }
38
+ function formatAnswer(e) {
39
+ const r = e.response ?? {};
40
+ switch (e.interactionType) {
41
+ case "approval":
42
+ return (r.action === "approved" ? "APPROVED" : "REJECTED") + (r.reason ? `: ${r.reason}` : "");
43
+ case "choice":
44
+ return `CHOSE: ${r.selected}${r.customValue ? ` (custom: ${r.customValue})` : ""}`;
45
+ case "checklist":
46
+ return `SELECTED: ${(r.selected ?? []).join(", ") || "(none)"}`;
47
+ case "text_input":
48
+ return `TEXT: ${r.text ?? ""}`;
49
+ case "questions":
50
+ return (r.answers ?? []).map((a) => `${a.questionId}=${(a.selected ?? []).join("/")}${a.other ? ` (${a.other})` : ""}`).join("; ");
51
+ default:
52
+ return JSON.stringify(r);
53
+ }
54
+ }
55
+ async function ask(vessel, prompt, build, expectedType) {
56
+ var _a, _b;
57
+ await drain();
58
+ const { messageId } = await v.push({ vessel, vesselTitle, message: prompt, interaction: build });
59
+ console.error(`[vessels-mcp] ask posted ${messageId} (${expectedType}) on "${vessel}" \u2014 waiting\u2026`);
60
+ for (; ; ) {
61
+ const r = await v.poll({ ack: true, limit: 100 });
62
+ for (const e of r.events) {
63
+ if (e.type !== "interaction.response") continue;
64
+ const idMatch = e.messageId === messageId || ((_a = e.originMessage) == null ? void 0 : _a.id) === messageId;
65
+ const vesselMatch = ((_b = e.vessel) == null ? void 0 : _b.externalId) === vessel && e.interactionType === expectedType;
66
+ if (idMatch || vesselMatch) return formatAnswer(e);
67
+ }
68
+ if (!r.hasMore) await sleep(2e3);
69
+ }
70
+ }
71
+ var server = new McpServer({ name: "vessels", version: "0.2.0" });
72
+ server.registerTool(
73
+ "list_vessels",
74
+ {
75
+ title: "List vessels",
76
+ description: "List the vessels (conversations) in the workspace so you can pick one to act on. Returns one compact line per vessel: external_id, status, title, labels. Use the external_id as the `vessel` argument to other tools.",
77
+ inputSchema: {
78
+ limit: z.number().optional().describe("Max vessels to return (default 30)."),
79
+ archived: z.boolean().optional().describe("Include archived vessels (default false).")
80
+ }
81
+ },
82
+ async ({ limit, archived }) => {
83
+ const res = await fetch(`${baseUrl}/api/v1/vessels?archived=${archived ? "true" : "false"}`, {
84
+ headers: { Authorization: `Bearer ${apiKey}` }
85
+ });
86
+ const data = await res.json();
87
+ if (!res.ok) return ok(`error: ${data.error ?? res.status}`);
88
+ const rows = (data.vessels ?? []).slice(0, limit ?? 30);
89
+ if (!rows.length) return ok("(no vessels)");
90
+ const lines = rows.map((x) => {
91
+ const labels = (x.labels ?? []).length ? ` [${x.labels.join(",")}]` : "";
92
+ return `${x.external_id} \xB7${x.status}${x.pinned ? " \xB7pinned" : ""} "${x.title ?? ""}"${labels}`;
93
+ });
94
+ return ok(lines.join("\n"));
95
+ }
96
+ );
97
+ server.registerTool(
98
+ "read_vessel",
99
+ {
100
+ title: "Read a vessel",
101
+ description: 'Read the recent messages in a vessel as a compact transcript (oldest\u2192newest), so you have context before replying. Each line is "[source] text" (truncated). Use after list_vessels.',
102
+ inputSchema: {
103
+ vessel: z.string().optional().describe(`Vessel external_id (default "${defaultVessel}").`),
104
+ limit: z.number().optional().describe("How many recent messages (default 20).")
105
+ }
106
+ },
107
+ async ({ vessel, limit }) => {
108
+ const { messages: msgs } = await v.getMessages({ vessel: vessel ?? defaultVessel, limit: limit ?? 20 });
109
+ if (!msgs.length) return ok("(no messages)");
110
+ const lines = msgs.map((m) => {
111
+ let body = m.content ?? "";
112
+ if (!body && m.interaction) body = `[${m.interaction.type}] ${m.interaction.prompt ?? ""}`;
113
+ body = body.replace(/\s+/g, " ").slice(0, 200);
114
+ return `[${m.source}] ${body}`;
115
+ });
116
+ return ok(lines.join("\n"));
117
+ }
118
+ );
119
+ server.registerTool(
120
+ "notify",
121
+ {
122
+ title: "Notify (one-way)",
123
+ description: "Send a one-line status message to the human. Does not wait. Use to keep them informed.",
124
+ inputSchema: {
125
+ message: z.string().describe("The message text."),
126
+ vessel: z.string().optional().describe(`Vessel external_id (default "${defaultVessel}").`)
127
+ }
128
+ },
129
+ async ({ message, vessel }) => {
130
+ await v.push({ vessel: vessel ?? defaultVessel, vesselTitle, message });
131
+ return ok("ok");
132
+ }
133
+ );
134
+ server.registerTool(
135
+ "send",
136
+ {
137
+ title: "Send a message or artifact",
138
+ description: 'Post a message to a vessel. For a chat reply pass `message`. For a full-width artifact (proposal, report, diff, review) pass `body` (block markdown: headings/lists/tables/quotes) and optionally `title` + `card`. Also supports `pinCard` (persistent header), `labels`, `suggestions` (quick-reply chips), `previewUrl` (a link card), and `status` to set the vessel state. Returns the new message id as "mid:<id>".',
139
+ inputSchema: {
140
+ message: z.string().optional().describe("Chat-bubble text (use this OR body)."),
141
+ body: z.string().optional().describe("Surface artifact body \u2014 block markdown."),
142
+ title: z.string().optional().describe("Surface heading."),
143
+ card: z.object({ title: z.string().optional(), fields: z.array(z.object({ label: z.string(), value: z.string() })) }).optional().describe("Glance-facts card (label/value rows)."),
144
+ pinCard: z.object({ title: z.string().optional(), fields: z.array(z.object({ label: z.string(), value: z.string() })) }).optional().describe("Persistent pinned header card for the vessel."),
145
+ labels: z.array(z.string()).optional().describe("Triage labels (replace semantics)."),
146
+ suggestions: z.array(z.string()).optional().describe("Quick-reply suggestion chips."),
147
+ previewUrl: z.string().optional().describe("A single URL rendered as a link card."),
148
+ status: z.enum(["active", "waiting", "resolved"]).optional().describe("Set the vessel status."),
149
+ vessel: z.string().optional().describe(`Vessel external_id (default "${defaultVessel}").`)
150
+ }
151
+ },
152
+ async ({ message, body, title, card, pinCard, labels, suggestions, previewUrl, status, vessel }) => {
153
+ const common = {
154
+ vessel: vessel ?? defaultVessel,
155
+ vesselTitle,
156
+ ...title ? { title } : {},
157
+ ...card ? { card } : {},
158
+ ...pinCard ? { pinCard } : {},
159
+ ...labels ? { labels } : {},
160
+ ...suggestions ? { suggestions } : {},
161
+ ...previewUrl ? { previewUrl } : {},
162
+ ...status ? { vesselStatus: status } : {}
163
+ };
164
+ if (!message && !body) return ok("error: provide `message` or `body`");
165
+ const res = body ? await v.surface({ ...common, body }) : await v.push({ ...common, message });
166
+ return ok(`mid:${res.messageId}`);
167
+ }
168
+ );
169
+ server.registerTool(
170
+ "ask_human",
171
+ {
172
+ title: "Ask the human (blocks until answered)",
173
+ description: 'Ask the operator a question via Vessels and BLOCK until they answer (may take a long time \u2014 that is fine). Types: "approval" (yes/no \u2192 APPROVED/REJECTED, default), "choice" (pick one of `choices`), "checklist" (pick many of `choices`), "text" (free text), "questions" (a small multi-question form via `questions`). Returns the answer as a terse string.',
174
+ inputSchema: {
175
+ prompt: z.string().describe("The question/heading. Be specific; include what you did."),
176
+ type: z.enum(["approval", "choice", "checklist", "text", "questions"]).optional().describe('Default "approval".'),
177
+ choices: z.array(z.string()).optional().describe("Options for choice/checklist."),
178
+ minSelections: z.number().optional().describe("Checklist: minimum picks."),
179
+ multiline: z.boolean().optional().describe("Text: allow multiline."),
180
+ reasonRequired: z.boolean().optional().describe("Approval: require a typed reason."),
181
+ questions: z.array(
182
+ z.object({
183
+ id: z.string(),
184
+ question: z.string(),
185
+ header: z.string().optional(),
186
+ options: z.array(z.object({ id: z.string(), label: z.string(), description: z.string().optional() })),
187
+ multiSelect: z.boolean().optional()
188
+ })
189
+ ).optional().describe('For type "questions": 1\u20134 questions, each with 2\u20134 options.'),
190
+ vessel: z.string().optional().describe(`Vessel external_id (default "${defaultVessel}").`)
191
+ }
192
+ },
193
+ async ({ prompt, type, choices, minSelections, multiline, reasonRequired, questions, vessel }) => {
194
+ const tgt = vessel ?? defaultVessel;
195
+ const t = type ?? "approval";
196
+ let build;
197
+ let expected = "approval";
198
+ if (t === "choice") {
199
+ build = v.choice({ prompt, options: (choices ?? []).map((c) => ({ id: c, label: c })), allowCustom: true });
200
+ expected = "choice";
201
+ } else if (t === "checklist") {
202
+ build = v.checklist({ prompt, options: (choices ?? []).map((c) => ({ id: c, label: c })), minSelections });
203
+ expected = "checklist";
204
+ } else if (t === "text") {
205
+ build = v.textInput({ prompt, multiline });
206
+ expected = "text_input";
207
+ } else if (t === "questions") {
208
+ build = v.questions({ prompt, questions: questions ?? [] });
209
+ expected = "questions";
210
+ } else {
211
+ build = v.approval({ prompt, reasonRequired });
212
+ expected = "approval";
213
+ }
214
+ return ok(await ask(tgt, prompt, build, expected));
215
+ }
216
+ );
217
+ server.registerTool(
218
+ "wait_for_reply",
219
+ {
220
+ title: "Wait for a free-text reply (blocks)",
221
+ description: "Optionally post `prompt`, then BLOCK until the human sends a free-text message in the vessel, and return that message text. Use for open-ended back-and-forth (vs ask_human for structured decisions).",
222
+ inputSchema: {
223
+ prompt: z.string().optional().describe("Optional message to post before waiting."),
224
+ vessel: z.string().optional().describe(`Vessel external_id (default "${defaultVessel}").`)
225
+ }
226
+ },
227
+ async ({ prompt, vessel }) => {
228
+ var _a, _b;
229
+ const tgt = vessel ?? defaultVessel;
230
+ await drain();
231
+ if (prompt) await v.push({ vessel: tgt, vesselTitle, message: prompt });
232
+ for (; ; ) {
233
+ const r = await v.poll({ ack: true, limit: 100 });
234
+ for (const e of r.events) {
235
+ if (e.type === "message.user" && ((_a = e.vessel) == null ? void 0 : _a.externalId) === tgt) {
236
+ return ok(((_b = e.message) == null ? void 0 : _b.content) ?? "");
237
+ }
238
+ }
239
+ if (!r.hasMore) await sleep(2e3);
240
+ }
241
+ }
242
+ );
243
+ server.registerTool(
244
+ "update_message",
245
+ {
246
+ title: "Edit a message",
247
+ description: "Patch an existing message by id (from a `send`/`activity` result). Update its `message` text, `card`, `title`, or `status`. Useful to revise an artifact or close out a vessel.",
248
+ inputSchema: {
249
+ messageId: z.string().describe('The message id (e.g. from "mid:<id>").'),
250
+ message: z.string().optional(),
251
+ title: z.string().optional(),
252
+ card: z.object({ title: z.string().optional(), fields: z.array(z.object({ label: z.string(), value: z.string() })) }).optional(),
253
+ status: z.enum(["active", "waiting", "resolved"]).optional().describe("Set the vessel status.")
254
+ }
255
+ },
256
+ async ({ messageId, message, title, card, status }) => {
257
+ const patch = {
258
+ ...message ? { content: message } : {},
259
+ ...title ? { title } : {},
260
+ ...card ? { card } : {},
261
+ ...status ? { vesselStatus: status } : {}
262
+ };
263
+ await v.editMessage(messageId, patch);
264
+ return ok("ok");
265
+ }
266
+ );
267
+ server.registerTool(
268
+ "activity",
269
+ {
270
+ title: "Drive a working card",
271
+ description: 'Show a live "working" card. action "start" opens it (returns "mid:<id>"); "update" sets the `plan` (full todo list, each "label" or "label|done"/"label|in_progress"), appends a `step` label, or sets `status` (working/awaiting_input); "finish" seals it. Pass the start id as `workId` for update/finish.',
272
+ inputSchema: {
273
+ action: z.enum(["start", "update", "finish"]),
274
+ workId: z.string().optional().describe('Message id from "start" (required for update/finish).'),
275
+ label: z.string().optional().describe("start: the card headline."),
276
+ plan: z.array(z.string()).optional().describe('update: full todo list ("label" or "label|done"|"label|in_progress").'),
277
+ step: z.string().optional().describe("update: append a step with this label."),
278
+ status: z.enum(["working", "awaiting_input"]).optional().describe("update: lifecycle."),
279
+ vessel: z.string().optional().describe(`Vessel external_id (default "${defaultVessel}").`)
280
+ }
281
+ },
282
+ async ({ action, workId, label, plan, step, status, vessel }) => {
283
+ const tgt = vessel ?? defaultVessel;
284
+ const todos = plan == null ? void 0 : plan.map((p) => {
285
+ const [l, s] = p.split("|");
286
+ return { label: l.trim(), ...s ? { status: s.trim() } : {} };
287
+ });
288
+ if (action === "start") {
289
+ const res = await v.push({
290
+ vessel: tgt,
291
+ vesselTitle,
292
+ message: label ?? "Working\u2026",
293
+ agentActivity: todos ? { todos } : { status: "working" }
294
+ });
295
+ return ok(`mid:${res.messageId}`);
296
+ }
297
+ if (!workId) return ok("error: workId required for update/finish");
298
+ if (action === "finish") {
299
+ await v.editMessage(workId, { agentActivity: null });
300
+ return ok("ok");
301
+ }
302
+ if (todos) await v.editMessage(workId, { agentActivity: { todos } });
303
+ if (step) await v.editMessage(workId, { agentActivity: { type: "tool_use", label: step } });
304
+ if (status) await v.editMessage(workId, { agentActivity: { status } });
305
+ return ok("ok");
306
+ }
307
+ );
308
+ await server.connect(new StdioServerTransport());
309
+ console.error(`[vessels-mcp] ready \u2014 default vessel "${defaultVessel}" @ ${baseUrl}`);
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "vessels-mcp",
3
+ "version": "0.2.0",
4
+ "description": "Vessels MCP server — give any MCP client (Claude Code, Cursor, …) a tool to message a human and BLOCK until they answer.",
5
+ "type": "module",
6
+ "bin": {
7
+ "vessels-mcp": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "license": "MIT",
13
+ "keywords": [
14
+ "ai",
15
+ "agents",
16
+ "vessels",
17
+ "mcp",
18
+ "model-context-protocol"
19
+ ],
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.0.0",
22
+ "zod": "^3.22",
23
+ "vessels-sdk": "0.16.0"
24
+ },
25
+ "devDependencies": {
26
+ "tsup": "^8.5.1",
27
+ "typescript": "^5",
28
+ "@types/node": "^25.5.2"
29
+ },
30
+ "scripts": {
31
+ "build": "tsup",
32
+ "dev": "tsup --watch"
33
+ }
34
+ }