@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 +1 -1
- package/src/client.js +28 -0
- package/src/server.js +94 -20
package/package.json
CHANGED
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
|
-
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
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
|
-
|
|
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 "${
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
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.
|
|
183
|
-
"
|
|
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 (
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)`);
|