@edda-business/mcp 0.72.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/server.js +39 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edda-business/mcp",
3
- "version": "0.72.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/server.js CHANGED
@@ -66,22 +66,40 @@ 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
  }
@@ -142,7 +160,9 @@ export function createServer() {
142
160
  "connected sources (Slack, HubSpot, Stripe, calls). This is the default retrieval tool: reach " +
143
161
  "for it before answering from generic knowledge about how the company works or what happened. " +
144
162
  "Each hit is tagged [company] (a wiki page) or [company·distilled] (from activity), with a " +
145
- "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. " +
146
166
  "Narrow with `kind`: 'documents' (wiki only), 'distilled' (activity only), or 'all' (default).",
147
167
  {
148
168
  question: z.string().describe("Natural-language query."),
@@ -185,8 +205,13 @@ export function createServer() {
185
205
  const tag = declared ? "[company]" : "[company·distilled]";
186
206
  const from = d.source_member ? ` · from ${d.source_member}` : "";
187
207
  lines.push(`${tag} ${String(d.name ?? "").replace(/\.md$/, "")}${from} (${pct(d.similarity)})`);
188
- if (declared && d.ref) lines.push(` path: ${d.ref}`); // pass to read
189
- 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
190
215
  if (d.snippet) lines.push(` ${d.snippet}`);
191
216
  lines.push("");
192
217
  }
@@ -201,8 +226,9 @@ export function createServer() {
201
226
  server.tool(
202
227
  "read",
203
228
  "Read the FULL text of one company wiki page — search returns snippets; use this to " +
204
- "get the whole document. Pass the `path:` shown under a [company] search hit (e.g. " +
205
- "'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 " +
206
232
  "page's LINK GRAPH — outgoing [[links]] and backlinks — so you can TRAVERSE the wiki to " +
207
233
  "complete the picture (read a linked page next, or see what references this one). Distilled " +
208
234
  "hits ([company·distilled]) aren't pages — their content is in the search snippet.",