@thehammer/danx-dashboard-mcp 0.1.216 → 0.1.219

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/lifecycle.js CHANGED
@@ -36,6 +36,8 @@
36
36
  * - Descriptions trimmed of DX-card historical narration where cheap to do
37
37
  * without re-deriving the byte-for-byte target DX-3394 measured.
38
38
  */
39
+ import { readFile, stat } from "node:fs/promises";
40
+ import { basename, isAbsolute } from "node:path";
39
41
  import { z } from "zod";
40
42
  /**
41
43
  * The five canonical agent-visible `danxbot_complete` statuses. DX-3394
@@ -114,15 +116,37 @@ async function postWorkerRoute(url, body, toolName, bearer) {
114
116
  return { raw: text };
115
117
  }
116
118
  }
117
- function parseOptionalAbsolutePathArray(field, value) {
118
- if (value === undefined)
119
+ /** Must match `MAX_DECODED_BYTES` in danxbot's `src/issues/attachment-limits.ts` (duplicated, not imported: this package is published on its own). */
120
+ const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
121
+ /**
122
+ * DX-4251 — read each attached path HERE, in the MCP server process beside the
123
+ * agent, and return its bytes. The dashboard that serves the reply route can
124
+ * run on a different host than the agent (dedicated topology), so a path could
125
+ * never be resolved over there.
126
+ */
127
+ async function readAttachments(paths) {
128
+ if (paths === undefined)
119
129
  return [];
120
- return value.map((entry, idx) => {
121
- if (!entry.startsWith("/")) {
122
- throw new Error(`field "${field}"[${idx}] must be an absolute path (got "${entry}")`);
130
+ return Promise.all(paths.map(async (entry, idx) => {
131
+ if (!isAbsolute(entry)) {
132
+ throw new Error(`field "files"[${idx}] must be an absolute path (got "${entry}")`);
123
133
  }
124
- return entry;
125
- });
134
+ const info = await stat(entry);
135
+ // DX-4251: a directory stats as size 0 (Windows) or 4096 (Linux); name it, never read it as a file.
136
+ if (!info.isFile())
137
+ throw new Error(`field "files"[${idx}] "${entry}" is not a file`);
138
+ if (info.size === 0)
139
+ throw new Error(`field "files"[${idx}] "${entry}" is an empty file`);
140
+ if (info.size > MAX_ATTACHMENT_BYTES) {
141
+ throw new Error(`field "files"[${idx}] "${entry}" is ${info.size} bytes, over the ${MAX_ATTACHMENT_BYTES}-byte attachment limit`);
142
+ }
143
+ return { filename: basename(entry), base64: (await readFile(entry)).toString("base64") };
144
+ }));
145
+ }
146
+ /** A refused callback's body is the only place the server says why — relay it, never just the status. */
147
+ async function responseFailure(label, response) {
148
+ const text = await response.text();
149
+ return new Error(`${label} returned HTTP ${response.status}${text ? `: ${text}` : ""}`);
126
150
  }
127
151
  /**
128
152
  * Registers every lifecycle tool on `server` via the caller's `strictTool`
@@ -190,25 +214,26 @@ export function registerLifecycleTools(strictTool) {
190
214
  });
191
215
  // ---------------- danxbot_slack_reply ----------------
192
216
  const slackReply = strictTool("danxbot_slack_reply", "Post the FINAL user-facing reply to the originating Slack thread. Call exactly once per dispatch, " +
193
- "immediately before danxbot_complete. Optionally attach result files via files[] (absolute local paths " +
194
- "on the shared filesystem) — text + attachments land as ONE atomic thread post. For intermediate " +
217
+ "immediately before danxbot_complete. Optionally attach result files via files[] (absolute paths of files on YOUR own " +
218
+ "disk — their bytes are uploaded, so this works whichever host the dashboard runs on) — text + attachments " +
219
+ "land as ONE atomic thread post. For intermediate " +
195
220
  "progress use danxbot_slack_post_update instead.", {
196
221
  text: z.string().describe("The final reply text to post to the Slack thread."),
197
222
  files: z
198
223
  .array(z.string())
199
224
  .optional()
200
- .describe("Optional absolute local paths to files to attach to the reply."),
225
+ .describe("Optional absolute paths of files on your own disk to attach; their bytes are uploaded."),
201
226
  }, async (args) => {
202
227
  const url = readLifecycleUrlsFromEnv().slackReply;
203
228
  if (!url)
204
229
  throw new Error("danxbot_slack_reply called outside a Slack dispatch");
205
- const files = parseOptionalAbsolutePathArray("files", args.files);
230
+ const files = await readAttachments(args.files);
206
231
  const body = { text: args.text };
207
232
  if (files.length > 0)
208
233
  body.files = files;
209
234
  const response = await postJson(url, body, dashboardBearer());
210
235
  if (!response.ok)
211
- throw new Error(`Slack reply API returned HTTP ${response.status}`);
236
+ throw await responseFailure("Slack reply API", response);
212
237
  return {
213
238
  content: [
214
239
  {
@@ -228,7 +253,7 @@ export function registerLifecycleTools(strictTool) {
228
253
  throw new Error("danxbot_slack_post_update called outside a Slack dispatch");
229
254
  const response = await postJson(url, { text: args.text }, dashboardBearer());
230
255
  if (!response.ok)
231
- throw new Error(`Slack update API returned HTTP ${response.status}`);
256
+ throw await responseFailure("Slack update API", response);
232
257
  return { content: [{ type: "text", text: "Update posted to Slack thread" }] };
233
258
  });
234
259
  // ---------------- link_thread_to_issue ----------------
@@ -1,7 +1,9 @@
1
1
  const TERMS_ACCEPTANCE_PATH = "/api/account/terms-acceptance";
2
2
  // Mirrors `termsAcceptUrl("/")` in src/lib/terms-accept-path.ts (a published package cannot import it);
3
3
  // terms-gate.integration.test.ts pins the server's link to the same literal. Only meaningful for an
4
- // operator session: a dispatch's DANXBOT_DASHBOARD_URL is internal, and a dispatch's own agent credential is never terms-gated.
4
+ // operator session: a dispatch's DANXBOT_DASHBOARD_URL is internal, and a dispatch's own key is its
5
+ // owner's API key (DX-4191), so a dispatched agent can meet this refusal too and hands the link to the
6
+ // owner's operator.
5
7
  const ACCEPT_SCREEN_PATH = "/terms/accept?return=%2F";
6
8
  /** Keep in step with `termsRefusalInstruction` in src/dashboard/route-policy.ts. */
7
9
  export function haltText(acceptUrl, owner) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.216",
3
+ "version": "0.1.219",
4
4
  "description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, danxbot_complete, and other per-dispatch lifecycle callbacks) (DX-3465, PLN-11 R-25).",
5
5
  "license": "MIT",
6
6
  "type": "module",