@edda-business/mcp 0.71.0 → 0.73.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.73.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
 
@@ -66,28 +66,68 @@ async function resolvePathToId(path) {
66
66
  const base = norm(segs[segs.length - 1] ?? "");
67
67
  const folderSegs = segs.length > 1 ? segs.slice(0, -1).map(norm) : null;
68
68
  let cands = pages.filter((p) => norm(p.name) === base);
69
- // EDDA-91: a folder-qualified request MUST honour its folder. The old basename-only match
70
- // silently substituted an unrelated same-named page from the caller's visible scope when the
71
- // requested folder was out of scope (or misspelled) — an exact read must return THAT document
72
- // or a truthful unavailable, never masquerade. The message deliberately does not reveal
73
- // whether the path exists for someone else.
69
+
70
+ // EDDA-91: a folder-qualified request MUST honour its folder. A basename-only match silently
71
+ // substituted an unrelated same-named page — an exact read returns THAT document or a truthful
72
+ // unavailable, never a masquerade.
73
+ //
74
+ // EDDA-151: it must honour the WHOLE folder path. The previous check asked only whether the
75
+ // page's leaf folder name appeared somewhere among the requested segments, so
76
+ // `AION/Context/_context` matched every project's `Context/_context.md` — six indistinguishable
77
+ // candidates, and the agent opening unrelated projects to find the one it asked for. Match
78
+ // against the page's full path (now returned by /pages) as a trailing sequence of segments, so
79
+ // `AION/Context/_context` matches `Projects/AION/Context/_context.md` and nothing else.
74
80
  if (folderSegs) {
75
- cands = cands.filter((p) => p.folder && folderSegs.includes(norm(p.folder)));
81
+ const wanted = segs.map(norm);
82
+ const scored = cands.map((p) => {
83
+ const full = String(p.path || [p.folder, p.name].filter(Boolean).join("/")).split("/").map(norm);
84
+ // strip the extension from the final segment for comparison
85
+ const tail = full.slice(-wanted.length);
86
+ const exact = tail.length === wanted.length && tail.every((seg, i) => seg === wanted[i]);
87
+ return { p, exact };
88
+ });
89
+ const exact = scored.filter((x) => x.exact).map((x) => x.p);
90
+ // Fall back to the old leaf-name behaviour only when nothing matches the full path, so a
91
+ // legacy flat row (no folder tree) still resolves.
92
+ cands = exact.length ? exact : cands.filter((p) => !p.path && p.folder && folderSegs.includes(norm(p.folder)));
76
93
  }
94
+
77
95
  if (!cands.length) {
78
96
  return { error: folderSegs
79
97
  ? `"${String(path)}" is not available here — it either does not exist or is outside what this seat can read. Use search to see what you can access.`
80
98
  : `No company page named "${String(path).split("/").pop()}". Use search to find the exact path first.` };
81
99
  }
82
100
  if (cands.length > 1) {
83
- const opts = cands.map((p) => ` • ${[p.folder, p.name].filter(Boolean).join("/")} (id: ${p.id})`).join("\n");
84
- return { error: `Several pages match "${base}". Pass one of these ids as \`id\`:\n${opts}` };
101
+ const opts = cands.map((p) => ` • ${p.path || [p.folder, p.name].filter(Boolean).join("/")} (id: ${p.id})`).join("\n");
102
+ return { error: `Several pages match "${String(path)}". Pass one of these ids as \`id\`:\n${opts}` };
85
103
  }
86
104
  return { id: cands[0].id };
87
105
  }
88
106
 
89
107
  // ─── factory ──────────────────────────────────────────────────────────────────
90
108
 
109
+ // A real file goes up as a STREAM with a declared hash, not as base64 in a tool argument. The
110
+ // agent names a path on its own disk; the MCP reads it, uploads the bytes to the original store,
111
+ // and stages a candidate that references the registered original. `sha256` is declared so the
112
+ // server fails closed if what arrived is not what the agent read.
113
+ async function uploadLocalFile(filePath, { sourceId } = {}) {
114
+ const { readFile } = await import("node:fs/promises");
115
+ const { basename } = await import("node:path");
116
+ const crypto = await import("node:crypto");
117
+ let bytes;
118
+ try { bytes = await readFile(filePath); }
119
+ catch (e) { throw new Error(`Could not read ${filePath}: ${e.code === "ENOENT" ? "no such file" : e.message}`); }
120
+ const sha256 = crypto.createHash("sha256").update(bytes).digest("hex");
121
+ const name = basename(filePath);
122
+ const r = await postRaw("/v2/company/river/original", bytes, {
123
+ // The hash doubles as the idempotency key: the same bytes re-uploaded register once.
124
+ "X-Source-Id": sourceId || `mcp:${sha256}`,
125
+ "X-Original-Filename": encodeURIComponent(name),
126
+ "X-Sha256": sha256,
127
+ });
128
+ return { originalId: r?.original?.id ?? null, name, sha256, bytes: r?.original?.byte_size ?? bytes.length };
129
+ }
130
+
91
131
  export function createServer() {
92
132
  // EfB seat boundary: with EDDA_MCP_READONLY set (the flag Hermes EfB box profiles use),
93
133
  // the personal agent gets EXACTLY search + read — the write tools (push/update/create) are
@@ -120,7 +160,9 @@ export function createServer() {
120
160
  "connected sources (Slack, HubSpot, Stripe, calls). This is the default retrieval tool: reach " +
121
161
  "for it before answering from generic knowledge about how the company works or what happened. " +
122
162
  "Each hit is tagged [company] (a wiki page) or [company·distilled] (from activity), with a " +
123
- "snippet; wiki hits include a `path:` you can pass to read for the full text. " +
163
+ "snippet. A [company] hit carries an `id:` pass THAT to read, it opens exactly the document " +
164
+ "you found. The `path:` is shown for context; prefer the id. Every hit returned is one you " +
165
+ "can actually open. " +
124
166
  "Narrow with `kind`: 'documents' (wiki only), 'distilled' (activity only), or 'all' (default).",
125
167
  {
126
168
  question: z.string().describe("Natural-language query."),
@@ -163,8 +205,13 @@ export function createServer() {
163
205
  const tag = declared ? "[company]" : "[company·distilled]";
164
206
  const from = d.source_member ? ` · from ${d.source_member}` : "";
165
207
  lines.push(`${tag} ${String(d.name ?? "").replace(/\.md$/, "")}${from} (${pct(d.similarity)})`);
166
- if (declared && d.ref) lines.push(` path: ${d.ref}`); // pass to read
167
- else if (d.ref) lines.push(` source: ${d.ref}`); // distilled: origin, read-only
208
+ // EDDA-151: the ID is what reliably opens the document. The API always returned it and
209
+ // this tool never printed it, so the agent had to go back through a path — and a path
210
+ // built from the leaf folder name could not tell one project's `Context/_context.md` from
211
+ // another's. Print the id first and say plainly that it is the thing to use.
212
+ if (declared && d.id) lines.push(` id: ${d.id} ← pass this to read`);
213
+ if (declared && d.ref) lines.push(` path: ${d.ref}`);
214
+ else if (!declared && d.ref) lines.push(` source: ${d.ref}`); // distilled: origin, read-only
168
215
  if (d.snippet) lines.push(` ${d.snippet}`);
169
216
  lines.push("");
170
217
  }
@@ -179,8 +226,9 @@ export function createServer() {
179
226
  server.tool(
180
227
  "read",
181
228
  "Read the FULL text of one company wiki page — search returns snippets; use this to " +
182
- "get the whole document. Pass the `path:` shown under a [company] search hit (e.g. " +
183
- "'Company/Policies/Refund Policy.md'), or an `id` if you already have one. Also returns the " +
229
+ "get the whole document. PREFER the `id:` from a [company] search hit it opens exactly that " +
230
+ "document with no ambiguity. A `path` also works, but pass the FULL project-qualified path as " +
231
+ "search printed it (e.g. 'Projects/AION/Context/_context.md'), not a fragment. Also returns the " +
184
232
  "page's LINK GRAPH — outgoing [[links]] and backlinks — so you can TRAVERSE the wiki to " +
185
233
  "complete the picture (read a linked page next, or see what references this one). Distilled " +
186
234
  "hits ([company·distilled]) aren't pages — their content is in the search snippet.",
@@ -288,6 +336,7 @@ export function createServer() {
288
336
  {
289
337
  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
338
  content: z.string().optional().describe("The full markdown content, when you are sharing text you wrote. Omit when sending a FILE via file_base64."),
339
+ 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
340
  file_base64: z.string().optional().describe(
292
341
  "A real FILE, base64-encoded — a proposal, invoice, report, financial model, scan. The server extracts the text: " +
293
342
  "PDF (scans are OCR'd), Word, Excel and legacy .xls (one heading per sheet), images (OCR'd), CSV and plain text. " +
@@ -320,10 +369,18 @@ export function createServer() {
320
369
  "and are applied only when a person approves the item. Cite nothing yourself — the filed document becomes the evidence."
321
370
  ),
322
371
  },
323
- async ({ name, content, file_base64, mime, suggested, grade, audience, idempotency_key, claims }) => {
372
+ async ({ name, content, file_path, file_base64, mime, suggested, grade, audience, idempotency_key, claims }) => {
324
373
  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 });
374
+ if (!content && !file_base64 && !file_path) return text(`Give "${name}" either markdown content or a file (file_path) to stage.`);
375
+ // A path wins: stream the bytes into the original store first, then stage a candidate that
376
+ // references the registered original. The server extracts the text from the original.
377
+ let original_id = null;
378
+ if (file_path) {
379
+ const up = await uploadLocalFile(file_path, { sourceId: idempotency_key ? `mcp:${idempotency_key}` : undefined });
380
+ original_id = up.originalId;
381
+ if (!name) name = up.name;
382
+ }
383
+ const r = await post("/v2/company/river/stage", { name, content, file_base64, original_id, mime, suggested, grade, audience, idempotency_key, claims });
327
384
  const rc = r?.receipt ?? r;
328
385
  if (rc?.idempotent) return text(`Already staged — "${name}" is in the company Inbox (${rc.location}). Nothing duplicated.`);
329
386
  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 +424,8 @@ export function createServer() {
367
424
  items: z.array(z.object({
368
425
  name: z.string().describe("File/page name, e.g. 'Cook Street construction proposal.pdf'."),
369
426
  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."),
427
+ 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."),
428
+ 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
429
  mime: z.string().optional().describe("MIME type when known."),
372
430
  suggested: z.string().optional().describe("Optional destination HINT. A path you cannot see is dropped, and the item still lands in the Inbox."),
373
431
  grade: z.enum(["fact", "summary", "full"]).optional(),
@@ -386,10 +444,26 @@ export function createServer() {
386
444
  },
387
445
  async ({ items }) => {
388
446
  try {
389
- const r = await post("/v2/company/river/stage-batch", { candidates: items });
447
+ // Upload any local files first, so the batch body stays small however large the documents
448
+ // are. A per-file failure becomes that item's receipt rather than killing the whole run.
449
+ const prepared = [];
450
+ for (const it of items) {
451
+ if (!it.file_path) { prepared.push(it); continue; }
452
+ try {
453
+ const up = await uploadLocalFile(it.file_path, { sourceId: it.idempotency_key ? `mcp:${it.idempotency_key}` : undefined });
454
+ const { file_path: _drop, ...rest } = it;
455
+ prepared.push({ ...rest, name: it.name || up.name, original_id: up.originalId });
456
+ } catch (e) {
457
+ prepared.push({ ...it, __upload_error: String(e.message || e) });
458
+ }
459
+ }
460
+ const failedUploads = prepared.filter((p) => p.__upload_error);
461
+ const sendable = prepared.filter((p) => !p.__upload_error);
462
+ const r = await post("/v2/company/river/stage-batch", { candidates: sendable });
390
463
  const staged = r?.staged ?? 0;
391
464
  const failed = r?.failed ?? 0;
392
- const lines = [`Staged ${staged} item(s) into the company Inbox${failed ? `, ${failed} rejected` : ""}.`];
465
+ const lines = [`Staged ${staged} item(s) into the company Inbox${failed ? `, ${failed} rejected` : ""}${failedUploads.length ? `, ${failedUploads.length} unreadable` : ""}.`];
466
+ for (const f of failedUploads) lines.push(` ✗ ${f.name ?? f.file_path} — ${f.__upload_error}`);
393
467
  for (const rec of r?.receipts ?? []) {
394
468
  if (rec.staged === false) lines.push(` ✗ ${rec.name ?? "(unnamed)"} — ${rec.reason ?? rec.rejected ?? "unknown"}`);
395
469
  else if (rec.suggestion_dropped) lines.push(` • ${rec.name} — staged; your suggested destination was dropped (not visible to you)`);