@edda-business/mcp 0.65.0 → 0.66.1

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,11 +1,14 @@
1
1
  {
2
2
  "name": "@edda-business/mcp",
3
- "version": "0.65.0",
3
+ "version": "0.66.1",
4
4
  "description": "Edda — the company data layer for AI agents.",
5
5
  "license": "Apache-2.0",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
6
9
  "repository": {
7
10
  "type": "git",
8
- "url": "git+https://github.com/NousC/opennous.git",
11
+ "url": "git+https://github.com/EddaBusiness/edda.git",
9
12
  "directory": "apps/mcp"
10
13
  },
11
14
  "type": "module",
@@ -21,6 +24,7 @@
21
24
  "start": "node src/index.js",
22
25
  "dev:http": "node --watch src/http.js",
23
26
  "start:http": "node src/http.js",
27
+ "test": "node --test tests/*.test.mjs",
24
28
  "typecheck": "echo 'no ts in mcp — skipping'"
25
29
  },
26
30
  "keywords": [
package/src/client.js CHANGED
@@ -29,6 +29,15 @@ const envKey = () => resolvedEnv("EDDA_API_KEY") ?? resolvedEnv("NOUS_API_KEY");
29
29
  const envUrl = () => resolvedEnv("EDDA_API_URL") ?? resolvedEnv("NOUS_API_URL");
30
30
  const API_URL = envUrl() || "https://api.opennous.cloud";
31
31
 
32
+ // EfB seat boundary (EDDA_MCP_READONLY=1|true|yes — the stable flag name Hermes EfB box
33
+ // profiles set). In this mode the server registers ONLY search/read (see server.js), and the
34
+ // client FAILS CLOSED: it requires an explicitly configured API URL and key, and never falls
35
+ // back to the vendor cloud default — a seat's company questions must only ever reach the
36
+ // company's own instance. Read per-call so the flag is testable and honored at request time.
37
+ export function isReadOnly() {
38
+ return /^(1|true|yes)$/i.test(process.env.EDDA_MCP_READONLY ?? "");
39
+ }
40
+
32
41
  // Per-request key context for the hosted HTTP server. Empty in stdio mode.
33
42
  export const apiKeyStore = new AsyncLocalStorage();
34
43
 
@@ -61,7 +70,10 @@ function currentApiKey() {
61
70
 
62
71
  // Resolve the API base per call: env → config.json (set by the CLI login on self-host) → cloud
63
72
  // default. So a self-hoster who logs in via the CLI gets the MCP pointed at their own instance.
73
+ // In read-only seat mode there is NO cloud default — an unconfigured URL resolves to undefined
74
+ // and request() refuses to issue anything.
64
75
  function currentApiUrl() {
76
+ if (isReadOnly()) return envUrl() ?? fileApiUrl();
65
77
  return envUrl() ?? fileApiUrl() ?? "https://api.opennous.cloud";
66
78
  }
67
79
 
@@ -74,6 +86,12 @@ export function validateConfig() {
74
86
  "to set up from scratch), or set EDDA_API_KEY."
75
87
  );
76
88
  }
89
+ if (isReadOnly() && !currentApiUrl()) {
90
+ throw new Error(
91
+ "Read-only seat mode (EDDA_MCP_READONLY) requires an explicit company API URL — set EDDA_API_URL " +
92
+ "or log in with `edda login --url <company-api-url>`. There is no cloud fallback in this mode."
93
+ );
94
+ }
77
95
  }
78
96
 
79
97
  async function request(method, path, { body, query } = {}) {
@@ -81,6 +99,14 @@ async function request(method, path, { body, query } = {}) {
81
99
  if (!apiKey) {
82
100
  throw new Error("Missing Edda API key. Pass it as an Authorization: Bearer header.");
83
101
  }
102
+ if (isReadOnly() && !currentApiUrl()) {
103
+ // Fail closed BEFORE any network I/O: a seat without an explicit company URL must not
104
+ // leak its question (or key) to the vendor cloud default.
105
+ throw new Error(
106
+ "Read-only seat mode requires an explicit company API URL (EDDA_API_URL or the login config's apiUrl). " +
107
+ "No request was made."
108
+ );
109
+ }
84
110
 
85
111
  const url = new URL(path, currentApiUrl());
86
112
 
package/src/index.js CHANGED
@@ -10,13 +10,20 @@
10
10
  * Required env:
11
11
  * EDDA_API_KEY — workspace API key (Sources -> API Keys). Encodes the workspace + scope.
12
12
  * Optional:
13
- * EDDA_API_URL — API base URL for your instance, e.g. https://api.whitehayai.com
13
+ * EDDA_API_URL — API base URL for your instance, e.g. https://api.<client>
14
14
  * (Legacy NOUS_API_KEY / NOUS_API_URL are still accepted as a fallback.)
15
15
  */
16
16
 
17
17
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
18
18
  import { validateConfig } from "./client.js";
19
- import { createServer } from "./server.js";
19
+ import { createServer, SERVER_VERSION } from "./server.js";
20
+
21
+ // Edda Skeleton calls this after installation. It must be entirely local: no config lookup,
22
+ // validation or network access can make an installed package look unavailable.
23
+ if (process.argv.includes("--version") || process.argv.includes("-V")) {
24
+ console.log(SERVER_VERSION);
25
+ process.exit(0);
26
+ }
20
27
 
21
28
  // Advisory only — don't hard-exit if there's no key yet. The user may register
22
29
  // the MCP first and sign in after (`npx @edda-business/cli login`); the server must
package/src/server.js CHANGED
@@ -22,7 +22,8 @@
22
22
  * TOOLS:
23
23
  * search RETRIEVE across BOTH natures (documents + distilled), kind filter
24
24
  * read read one full wiki page (by path from a search result, or id)
25
- * push PUSH one or many files up into the company folders (shared wiki)
25
+ * submit_company_candidate STAGE one user-approved file into the company Inbox (the River places it)
26
+ * reconcile PROCESS the company Inbox — file staged items into the right folders
26
27
  * update edit an existing wiki page (replace or append)
27
28
  * create organize the wiki tree
28
29
  *
@@ -32,9 +33,9 @@
32
33
 
33
34
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
34
35
  import { z } from "zod";
35
- import { get, post, patch } from "./client.js";
36
+ import { get, post, patch, isReadOnly } from "./client.js";
36
37
 
37
- export const SERVER_VERSION = "0.65.0";
38
+ export const SERVER_VERSION = "0.66.0";
38
39
 
39
40
  // ─── helpers ──────────────────────────────────────────────────────────────────
40
41
 
@@ -80,6 +81,11 @@ async function resolvePathToId(path) {
80
81
  // ─── factory ──────────────────────────────────────────────────────────────────
81
82
 
82
83
  export function createServer() {
84
+ // EfB seat boundary: with EDDA_MCP_READONLY set (the flag Hermes EfB box profiles use),
85
+ // the personal agent gets EXACTLY search + read — the write tools (push/update/create) are
86
+ // never registered, so a member key that could overwrite shared pages can't do so from a
87
+ // seat. Without the flag, registration below is untouched five-tool behavior.
88
+ const readOnly = isReadOnly();
83
89
  const server = new McpServer({
84
90
  name: "edda",
85
91
  version: SERVER_VERSION,
@@ -189,44 +195,74 @@ export function createServer() {
189
195
  },
190
196
  );
191
197
 
198
+ // EfB read-only seat mode stops here: tools/list is exactly search + read, and the write
199
+ // tools below don't exist for the seat at all (unavailable, not merely rejected).
200
+ if (readOnly) return server;
201
+
192
202
  // ===========================================================================
193
- // TOOL: push — POST /v2/company/pages/batch
194
- // PUSH one or many files UP into the shared company folders (the personal→company promotion).
195
- // Hybrid write policy: a write role publishes live; a viewer sends the batch to the company
196
- // inbox to approve.
203
+ // TOOL: submit_company_candidate — POST /v2/company/river/stage
204
+ // The seat's ONE write: STAGE a user-approved item into the company Edda's Inbox. The seat
205
+ // never chooses the destination it hands over one exact item (and may SUGGEST a folder);
206
+ // the company app (the River) classifies + places it on reconcile. Mirrors the personal
207
+ // side: everything lands in the Inbox first, then gets routed.
197
208
  // ===========================================================================
198
209
  server.tool(
199
- "push",
200
- "PUSH a file (or several) UP into the COMPANY WIKIpromote knowledge from your own work into " +
201
- "the shared company folders every member's agent can read via search. Typically you " +
202
- "push a personal/working file up so the company has it. All pushed files go into the SAME " +
203
- "folder (resolved or created once). Name the target with `folder` a name or path like " +
204
- "'Projects/Data Centre' (top-level folders are fixed; a bare name becomes a project under " +
205
- "Projects) plus optional `subfolder`, or an existing `folder_id`. A write role publishes it " +
206
- "live (searchable now); a viewer's push lands in the company inbox for an admin to approve. " +
207
- "Max 50 files. This is the SHARED company wiki the user's own private files live on their disk.",
210
+ "submit_company_candidate",
211
+ "SHARE a file up into the COMPANY knowledgestage one user-approved item into the company " +
212
+ "Edda's Inbox. You do NOT pick where it lands: the company routes it into the right folder " +
213
+ "itself (on the next reconcile). You may optionally `suggest` a destination as a hint. Use " +
214
+ "this to promote a finished note from your own work into the shared company brain. Only stage " +
215
+ "what the person has approved sharing this leaves their private vault. (Their private files " +
216
+ "stay on their disk; this is the shared company layer.) This stages PAGES (content) only — " +
217
+ "NEVER folders: if the person asks to create a folder, call `create` instead; folder creation " +
218
+ "is direct and immediate, it does not go through the Inbox.",
208
219
  {
209
- folder: z.string().optional().describe("Folder to file all pages under, by NAME or path. Top-level is fixed — a bare name becomes a project under Projects; 'Top-Level/Sub' nests under a fixed folder. Created if missing."),
210
- subfolder: z.string().optional().describe("Optional subfolder within `folder`."),
211
- folder_id: z.string().optional().describe("Id of an existing folder (alternative to `folder`). Omit both for the default folder."),
212
- visibility: z.enum(["owner", "department", "company"]).optional().describe("Default visibility: 'department' (default), 'company' (everyone), or 'owner' (just you). A page can override its own."),
213
- pages: z.array(z.object({
214
- name: z.string().describe("File/page name, e.g. 'Refund Policy'. A '.md' suffix is added if missing."),
215
- content: z.string().optional().describe("The full markdown content."),
216
- visibility: z.enum(["owner", "department", "company"]).optional().describe("Optional per-file visibility override."),
217
- })).describe("The file(s) to push — one entry for a single file, many for a bulk push. Each { name, content, visibility? }."),
220
+ name: z.string().describe("File/page name, e.g. 'MCP Overview EFB'. A '.md' suffix is added if missing."),
221
+ content: z.string().describe("The full markdown content of the item to share."),
222
+ suggested: z.string().optional().describe("Optional destination HINT, e.g. 'Projects/Data Centre/Working Documents' or 'General Edda'. The company still resolves/creates and places it — this only nudges."),
223
+ grade: z.enum(["fact", "summary", "full"]).optional().describe("How much of the source this carries: a single fact, a summary, or the full document."),
224
+ audience: z.enum(["company-wide", "department", "owner", "filing-agent-decides"]).optional().describe("The MOST this may be seen by (an upper bound the company may narrow, never broaden). Default: filing-agent-decides."),
225
+ idempotency_key: z.string().optional().describe("Optional stable key so re-submitting the same approved item files it at most once."),
226
+ },
227
+ async ({ name, content, suggested, grade, audience, idempotency_key }) => {
228
+ try {
229
+ const r = await post("/v2/company/river/stage", { name, content, suggested, grade, audience, idempotency_key });
230
+ const rc = r?.receipt ?? r;
231
+ if (rc?.idempotent) return text(`Already staged — "${name}" is in the company Inbox (${rc.location}). Nothing duplicated.`);
232
+ 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.`);
233
+ if (rc?.staged === false) return text(`Couldn't stage "${name}": ${rc?.reason ?? "unknown error"}.`);
234
+ return text(`Staged "${name}" into the company Inbox (${rc?.location ?? "Inbox"}). The company will route it to the right folder on the next reconcile.`);
235
+ } catch (e) {
236
+ const msg = String(e?.message || e);
237
+ if (msg.includes("admins_only") || msg.includes("(403)")) return text("You don't have a role that can share into this company wiki (members are read-only).");
238
+ if (msg.includes("name_required")) return text("Give the item a name and content to stage.");
239
+ throw e;
240
+ }
218
241
  },
219
- async ({ folder, subfolder, folder_id, visibility, pages }) => {
242
+ );
243
+
244
+ // ===========================================================================
245
+ // TOOL: reconcile — POST /v2/company/river/reconcile
246
+ // Process the company Edda's Inbox: the River classifies + places each staged item into the
247
+ // right folder, embeds + links it. "Process the company inbox." Admin/ambassador only.
248
+ // ===========================================================================
249
+ server.tool(
250
+ "reconcile",
251
+ "PROCESS the company Inbox — have the company file every item waiting in its Inbox into the " +
252
+ "right folder (classify → place → make searchable). Run this after staging, or when the person " +
253
+ "says 'process the company inbox'. Optionally pass a single `stagingId` to file just that item. " +
254
+ "Returns where each item landed.",
255
+ { stagingId: z.string().optional().describe("Optional id of one staged item to file (from a submit_company_candidate receipt). Omit to process the whole Inbox.") },
256
+ async ({ stagingId }) => {
220
257
  try {
221
- const r = await post("/v2/company/pages/batch", { folder, subfolder, folder_id, visibility, pages });
222
- const where = r?.status === "live" ? "published live — searchable now" : "sent to the company inbox for an admin to approve";
223
- return text(`${r?.count ?? pages?.length ?? 0} file(s) ${where} in "${r?.folder ?? folder ?? "wiki"}".`);
258
+ const r = await post("/v2/company/river/reconcile", { stagingId });
259
+ const receipts = r?.receipts ?? [];
260
+ if (!receipts.length) return text("Nothing waiting in the company Inbox.");
261
+ const lines = receipts.map((x) => ` ${x.filed ? "✓ filed" : x.status} → ${x.location}${x.audience && x.audience !== "company" ? ` [${x.audience}]` : ""} (${x.reason})`);
262
+ return text(`Processed ${receipts.length} item(s) from the company Inbox:\n${lines.join("\n")}`);
224
263
  } catch (e) {
225
264
  const msg = String(e?.message || e);
226
- if (msg.includes("forbidden") || msg.includes("(403)")) return text("You don't have access to push to this company wiki.");
227
- if (msg.includes("bad_folder")) return text("That folder path couldn't be resolved or created — check the name, or pass folder_id.");
228
- if (msg.includes("too_many")) return text("Too many files — max 50 per call. Split into batches.");
229
- if (msg.includes("pages_required")) return text("Give at least one file ({ name, content }).");
265
+ if (msg.includes("admins_only") || msg.includes("(403)")) return text("Only an admin or ambassador can process the company Inbox.");
230
266
  throw e;
231
267
  }
232
268
  },
@@ -241,8 +277,8 @@ export function createServer() {
241
277
  "Edit an EXISTING company wiki page — revise it or append to it. Identify the page by `path` " +
242
278
  "(the `path:` from a search result) or `id`. By default the new `content` REPLACES the " +
243
279
  "page; set append:true to add to the end instead (kept for a rolling log or a running doc). " +
244
- "You can also rename it with `name`. Re-embedded so search picks up the change. For a NEW page " +
245
- "use push. Distilled items (from sources) are read-only and can't be edited here.",
280
+ "You can also rename it with `name`. Re-embedded so search picks up the change. To SHARE a NEW " +
281
+ "item into the company use submit_company_candidate. Distilled items (from sources) are read-only and can't be edited here.",
246
282
  {
247
283
  path: z.string().optional().describe("The page path from a search result's `path:` line. Provide this OR id."),
248
284
  id: z.string().optional().describe("The page id (advanced). Provide this OR path."),
@@ -288,9 +324,10 @@ export function createServer() {
288
324
  "are FIXED (Company · Projects · Decisions · Companies · People · Raw Documents · Archive) — you " +
289
325
  "cannot add new ones. A bare name (or a path not starting with a fixed folder) becomes a PROJECT " +
290
326
  "under Projects: 'Agency' → Projects/Agency. To nest under a specific top-level folder, start the " +
291
- "path with it, e.g. 'Company/Policies' or 'Projects/Data Centre/Specs'. A write role is required; " +
292
- "the folder appears immediately. Then push files into it with push using the same " +
293
- "path. Returns the folder id.",
327
+ "path with it, e.g. 'Company/Policies' or 'Projects/Data Centre/Specs'. When the person asks to " +
328
+ "create a folder, ALWAYS call this tool directly never stage a folder request into the Inbox " +
329
+ "(the Inbox is for page content only). A write role is required; the folder appears immediately, " +
330
+ "and you can suggest it as a destination when you submit_company_candidate. Returns the folder id.",
294
331
  { path: z.string().describe("Folder name or path. A bare name → a project under Projects; 'Top-Level/Sub' nests under a fixed top-level folder.") },
295
332
  async ({ path }) => {
296
333
  try {