@edda-business/mcp 0.71.0 → 0.72.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edda-business/mcp",
3
- "version": "0.71.0",
3
+ "version": "0.72.0",
4
4
  "description": "Edda — the company data layer for AI agents.",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {
package/src/client.js CHANGED
@@ -156,6 +156,34 @@ async function request(method, path, { body, query } = {}) {
156
156
  return res.json();
157
157
  }
158
158
 
159
+ // Stream raw bytes with metadata on headers — the shape POST /v2/company/river/original expects.
160
+ // A real file (a spreadsheet, a scanned contract) has no business being base64'd into a JSON tool
161
+ // argument: it inflates by a third, buffers whole in memory at both ends, and blows the body limit.
162
+ export async function postRaw(path, bytes, headers = {}) {
163
+ const apiKey = currentApiKey();
164
+ if (!apiKey) throw new Error("Missing Edda API key. Pass it as an Authorization: Bearer header.");
165
+ if (isReadOnly() && !currentApiUrl()) {
166
+ throw new Error("Read-only seat mode requires an explicit company API URL (EDDA_API_URL or the login config's apiUrl). No request was made.");
167
+ }
168
+ const url = new URL(path, currentApiUrl());
169
+ const res = await fetch(url.toString(), {
170
+ method: "POST",
171
+ headers: {
172
+ Authorization: `Bearer ${apiKey}`,
173
+ "Content-Type": "application/octet-stream",
174
+ "X-Edda-Client": "mcp",
175
+ ...headers,
176
+ },
177
+ body: bytes,
178
+ });
179
+ if (!res.ok) {
180
+ let err = {};
181
+ try { err = await res.json(); } catch { /* non-JSON body */ }
182
+ throw new Error(`Edda API error (${res.status}): ${err.error || err.detail || res.statusText}`);
183
+ }
184
+ return res.json();
185
+ }
186
+
159
187
  export function get(path, query) {
160
188
  return request("GET", path, { query });
161
189
  }
package/src/server.js CHANGED
@@ -33,7 +33,7 @@
33
33
 
34
34
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
35
35
  import { z } from "zod";
36
- import { get, post, patch, isReadOnly } from "./client.js";
36
+ import { get, post, patch, postRaw, isReadOnly } from "./client.js";
37
37
 
38
38
  export const SERVER_VERSION = "0.66.0";
39
39
 
@@ -88,6 +88,28 @@ async function resolvePathToId(path) {
88
88
 
89
89
  // ─── factory ──────────────────────────────────────────────────────────────────
90
90
 
91
+ // A real file goes up as a STREAM with a declared hash, not as base64 in a tool argument. The
92
+ // agent names a path on its own disk; the MCP reads it, uploads the bytes to the original store,
93
+ // and stages a candidate that references the registered original. `sha256` is declared so the
94
+ // server fails closed if what arrived is not what the agent read.
95
+ async function uploadLocalFile(filePath, { sourceId } = {}) {
96
+ const { readFile } = await import("node:fs/promises");
97
+ const { basename } = await import("node:path");
98
+ const crypto = await import("node:crypto");
99
+ let bytes;
100
+ try { bytes = await readFile(filePath); }
101
+ catch (e) { throw new Error(`Could not read ${filePath}: ${e.code === "ENOENT" ? "no such file" : e.message}`); }
102
+ const sha256 = crypto.createHash("sha256").update(bytes).digest("hex");
103
+ const name = basename(filePath);
104
+ const r = await postRaw("/v2/company/river/original", bytes, {
105
+ // The hash doubles as the idempotency key: the same bytes re-uploaded register once.
106
+ "X-Source-Id": sourceId || `mcp:${sha256}`,
107
+ "X-Original-Filename": encodeURIComponent(name),
108
+ "X-Sha256": sha256,
109
+ });
110
+ return { originalId: r?.original?.id ?? null, name, sha256, bytes: r?.original?.byte_size ?? bytes.length };
111
+ }
112
+
91
113
  export function createServer() {
92
114
  // EfB seat boundary: with EDDA_MCP_READONLY set (the flag Hermes EfB box profiles use),
93
115
  // the personal agent gets EXACTLY search + read — the write tools (push/update/create) are
@@ -288,6 +310,7 @@ export function createServer() {
288
310
  {
289
311
  name: z.string().describe("File/page name, e.g. 'MCP Overview EFB' or 'Q3 invoices.xlsx'. A '.md' suffix is added to markdown items if missing."),
290
312
  content: z.string().optional().describe("The full markdown content, when you are sharing text you wrote. Omit when sending a FILE via file_base64."),
313
+ file_path: z.string().optional().describe("Absolute path to a file on THIS machine — PDF, Word, Excel, image, CSV. PREFER THIS over file_base64 for any real file: the bytes stream up with a checksum instead of being encoded into this argument. Use file_base64 only when you hold the bytes in memory and have no path."),
291
314
  file_base64: z.string().optional().describe(
292
315
  "A real FILE, base64-encoded — a proposal, invoice, report, financial model, scan. The server extracts the text: " +
293
316
  "PDF (scans are OCR'd), Word, Excel and legacy .xls (one heading per sheet), images (OCR'd), CSV and plain text. " +
@@ -320,10 +343,18 @@ export function createServer() {
320
343
  "and are applied only when a person approves the item. Cite nothing yourself — the filed document becomes the evidence."
321
344
  ),
322
345
  },
323
- async ({ name, content, file_base64, mime, suggested, grade, audience, idempotency_key, claims }) => {
346
+ async ({ name, content, file_path, file_base64, mime, suggested, grade, audience, idempotency_key, claims }) => {
324
347
  try {
325
- if (!content && !file_base64) return text(`Give "${name}" either markdown content or a file (file_base64) to stage.`);
326
- const r = await post("/v2/company/river/stage", { name, content, file_base64, mime, suggested, grade, audience, idempotency_key, claims });
348
+ if (!content && !file_base64 && !file_path) return text(`Give "${name}" either markdown content or a file (file_path) to stage.`);
349
+ // A path wins: stream the bytes into the original store first, then stage a candidate that
350
+ // references the registered original. The server extracts the text from the original.
351
+ let original_id = null;
352
+ if (file_path) {
353
+ const up = await uploadLocalFile(file_path, { sourceId: idempotency_key ? `mcp:${idempotency_key}` : undefined });
354
+ original_id = up.originalId;
355
+ if (!name) name = up.name;
356
+ }
357
+ const r = await post("/v2/company/river/stage", { name, content, file_base64, original_id, mime, suggested, grade, audience, idempotency_key, claims });
327
358
  const rc = r?.receipt ?? r;
328
359
  if (rc?.idempotent) return text(`Already staged — "${name}" is in the company Inbox (${rc.location}). Nothing duplicated.`);
329
360
  if (rc?.rejected === "hash_mismatch") return text(`Couldn't stage "${name}": the content didn't match its declared checksum (it may have changed after approval). Re-capture and try again.`);
@@ -367,7 +398,8 @@ export function createServer() {
367
398
  items: z.array(z.object({
368
399
  name: z.string().describe("File/page name, e.g. 'Cook Street construction proposal.pdf'."),
369
400
  content: z.string().optional().describe("Markdown content, when sharing text you wrote. Omit when sending a file."),
370
- file_base64: z.string().optional().describe("The file itself, base64-encoded PDF, Word, Excel, image, CSV. Send the file rather than a summary of it."),
401
+ file_path: z.string().optional().describe("Absolute path to a file on THIS machine. PREFER THIS for any real file the bytes stream up with a checksum instead of riding inside this argument."),
402
+ file_base64: z.string().optional().describe("The file itself, base64-encoded. Only when you hold the bytes in memory and have no path. Send the file rather than a summary of it."),
371
403
  mime: z.string().optional().describe("MIME type when known."),
372
404
  suggested: z.string().optional().describe("Optional destination HINT. A path you cannot see is dropped, and the item still lands in the Inbox."),
373
405
  grade: z.enum(["fact", "summary", "full"]).optional(),
@@ -386,10 +418,26 @@ export function createServer() {
386
418
  },
387
419
  async ({ items }) => {
388
420
  try {
389
- const r = await post("/v2/company/river/stage-batch", { candidates: items });
421
+ // Upload any local files first, so the batch body stays small however large the documents
422
+ // are. A per-file failure becomes that item's receipt rather than killing the whole run.
423
+ const prepared = [];
424
+ for (const it of items) {
425
+ if (!it.file_path) { prepared.push(it); continue; }
426
+ try {
427
+ const up = await uploadLocalFile(it.file_path, { sourceId: it.idempotency_key ? `mcp:${it.idempotency_key}` : undefined });
428
+ const { file_path: _drop, ...rest } = it;
429
+ prepared.push({ ...rest, name: it.name || up.name, original_id: up.originalId });
430
+ } catch (e) {
431
+ prepared.push({ ...it, __upload_error: String(e.message || e) });
432
+ }
433
+ }
434
+ const failedUploads = prepared.filter((p) => p.__upload_error);
435
+ const sendable = prepared.filter((p) => !p.__upload_error);
436
+ const r = await post("/v2/company/river/stage-batch", { candidates: sendable });
390
437
  const staged = r?.staged ?? 0;
391
438
  const failed = r?.failed ?? 0;
392
- const lines = [`Staged ${staged} item(s) into the company Inbox${failed ? `, ${failed} rejected` : ""}.`];
439
+ const lines = [`Staged ${staged} item(s) into the company Inbox${failed ? `, ${failed} rejected` : ""}${failedUploads.length ? `, ${failedUploads.length} unreadable` : ""}.`];
440
+ for (const f of failedUploads) lines.push(` ✗ ${f.name ?? f.file_path} — ${f.__upload_error}`);
393
441
  for (const rec of r?.receipts ?? []) {
394
442
  if (rec.staged === false) lines.push(` ✗ ${rec.name ?? "(unnamed)"} — ${rec.reason ?? rec.rejected ?? "unknown"}`);
395
443
  else if (rec.suggestion_dropped) lines.push(` • ${rec.name} — staged; your suggested destination was dropped (not visible to you)`);