@edda-business/mcp 0.61.0 → 0.63.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 +20 -21
- package/src/index.js +3 -2
- package/src/server.js +255 -143
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -24,7 +24,10 @@ function resolvedEnv(name) {
|
|
|
24
24
|
return v;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
// Prefer the EDDA_* env vars; fall back to the legacy NOUS_* names so existing configs keep working.
|
|
28
|
+
const envKey = () => resolvedEnv("EDDA_API_KEY") ?? resolvedEnv("NOUS_API_KEY");
|
|
29
|
+
const envUrl = () => resolvedEnv("EDDA_API_URL") ?? resolvedEnv("NOUS_API_URL");
|
|
30
|
+
const API_URL = envUrl() || "https://api.opennous.cloud";
|
|
28
31
|
|
|
29
32
|
// Per-request key context for the hosted HTTP server. Empty in stdio mode.
|
|
30
33
|
export const apiKeyStore = new AsyncLocalStorage();
|
|
@@ -35,17 +38,16 @@ export function runWithApiKey(apiKey, fn) {
|
|
|
35
38
|
return apiKeyStore.run({ apiKey }, fn);
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
// Credential written by
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
41
|
+
// Credential written by the CLI login (the browser device-auth flow). The CLI and the MCP server
|
|
42
|
+
// share a config.json, so a user who runs the login command gets a key — and, for self-host, the
|
|
43
|
+
// API URL — the MCP picks up on the next call, with no paste and no env var. Checks ~/.edda first,
|
|
44
|
+
// then legacy ~/.nous.
|
|
42
45
|
function readFileConfig() {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return JSON.parse(fs.readFileSync(path.join(dir, "config.json"), "utf8"));
|
|
46
|
-
} catch {
|
|
47
|
-
return null;
|
|
46
|
+
const dirs = [resolvedEnv("EDDA_CONFIG_DIR"), resolvedEnv("NOUS_CONFIG_DIR"), path.join(os.homedir(), ".edda"), path.join(os.homedir(), ".nous")].filter(Boolean);
|
|
47
|
+
for (const dir of dirs) {
|
|
48
|
+
try { return JSON.parse(fs.readFileSync(path.join(dir, "config.json"), "utf8")); } catch { /* try next */ }
|
|
48
49
|
}
|
|
50
|
+
return null;
|
|
49
51
|
}
|
|
50
52
|
function clean(v) {
|
|
51
53
|
return v && !String(v).includes("${") ? v : undefined; // drop unresolved ${...} markers
|
|
@@ -54,25 +56,22 @@ function fileApiKey() { return clean(readFileConfig()?.apiKey); }
|
|
|
54
56
|
function fileApiUrl() { return clean(readFileConfig()?.apiUrl); }
|
|
55
57
|
|
|
56
58
|
function currentApiKey() {
|
|
57
|
-
return apiKeyStore.getStore()?.apiKey ??
|
|
59
|
+
return apiKeyStore.getStore()?.apiKey ?? envKey() ?? fileApiKey();
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
// Resolve the API base per call: env →
|
|
61
|
-
//
|
|
62
|
-
// gets the MCP pointed at their own instance automatically.
|
|
62
|
+
// Resolve the API base per call: env → config.json (set by the CLI login on self-host) → cloud
|
|
63
|
+
// default. So a self-hoster who logs in via the CLI gets the MCP pointed at their own instance.
|
|
63
64
|
function currentApiUrl() {
|
|
64
|
-
return
|
|
65
|
+
return envUrl() ?? fileApiUrl() ?? "https://api.opennous.cloud";
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
// stdio-only preflight. A key may come from the env OR
|
|
68
|
-
//
|
|
69
|
-
// the user can run the login command after installing the plugin, and the key
|
|
70
|
-
// is resolved per-call.
|
|
68
|
+
// stdio-only preflight. A key may come from the env OR the CLI login credential file. Advisory —
|
|
69
|
+
// the server still starts without one so the user can log in after installing, key resolved per-call.
|
|
71
70
|
export function validateConfig() {
|
|
72
|
-
if (!
|
|
71
|
+
if (!envKey() && !fileApiKey()) {
|
|
73
72
|
throw new Error(
|
|
74
73
|
"No Edda API key found. Run `npx @edda-business/cli login` to sign in (or `npx @edda-business/cli init` " +
|
|
75
|
-
"to set up from scratch), or set
|
|
74
|
+
"to set up from scratch), or set EDDA_API_KEY."
|
|
76
75
|
);
|
|
77
76
|
}
|
|
78
77
|
}
|
package/src/index.js
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* variant see http.js; the tools themselves live in server.js.
|
|
9
9
|
*
|
|
10
10
|
* Required env:
|
|
11
|
-
*
|
|
11
|
+
* EDDA_API_KEY — workspace API key (Sources -> API Keys). Encodes the workspace + scope.
|
|
12
12
|
* Optional:
|
|
13
|
-
*
|
|
13
|
+
* EDDA_API_URL — API base URL for your instance, e.g. https://api.whitehayai.com
|
|
14
|
+
* (Legacy NOUS_API_KEY / NOUS_API_URL are still accepted as a fallback.)
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
package/src/server.js
CHANGED
|
@@ -13,21 +13,23 @@
|
|
|
13
13
|
* Tools, by group (v0.56 — consolidated, Cerebras-style):
|
|
14
14
|
* RETRIEVE search (unified, scope=all|company|personal|notes) · get_context ·
|
|
15
15
|
* get_account · who_knows · query · attention · verify
|
|
16
|
-
* WRITE save_note ·
|
|
17
|
-
*
|
|
16
|
+
* WRITE save_note · save_personal_file · update_personal_file · add_company_wiki_page ·
|
|
17
|
+
* add_company_wiki_pages (bulk) · create_company_wiki_folder
|
|
18
18
|
* FIX merge_contacts (action=merge|split)
|
|
19
19
|
* RUN list_integrations · connect_integration
|
|
20
20
|
*
|
|
21
21
|
* Deprecated aliases kept for back-compat (forward to the above, removed after a window):
|
|
22
22
|
* search_company_knowledge · search_my_vault · search_notes → search
|
|
23
|
-
* propose_vault_file →
|
|
23
|
+
* save_to_vault/propose_vault_file → save_personal_file · update_vault_file → update_personal_file
|
|
24
|
+
* propose_company_file → add_company_wiki_page · propose_company_files → add_company_wiki_pages
|
|
25
|
+
* create_folder → create_company_wiki_folder · unmerge_contacts → merge_contacts(action:split)
|
|
24
26
|
*/
|
|
25
27
|
|
|
26
28
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
27
29
|
import { z } from "zod";
|
|
28
30
|
import { get, post, del } from "./client.js";
|
|
29
31
|
|
|
30
|
-
export const SERVER_VERSION = "0.
|
|
32
|
+
export const SERVER_VERSION = "0.63.0";
|
|
31
33
|
|
|
32
34
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
33
35
|
|
|
@@ -78,7 +80,7 @@ export function createServer() {
|
|
|
78
80
|
description:
|
|
79
81
|
"Edda — the company knowledge layer for AI agents. The agent reads engineered, " +
|
|
80
82
|
"epistemics-tagged context instead of raw rows. Call get_context before preparing for a " +
|
|
81
|
-
"meeting or a decision about a person;
|
|
83
|
+
"meeting or a decision about a person; search for how the company works; " +
|
|
82
84
|
"save_note to keep a brief or transcript on a contact.",
|
|
83
85
|
icons: [
|
|
84
86
|
{ src: "https://opennous.cloud/newlogoP.png", mimeType: "image/png", sizes: ["64x64"] },
|
|
@@ -572,6 +574,80 @@ export function createServer() {
|
|
|
572
574
|
},
|
|
573
575
|
);
|
|
574
576
|
|
|
577
|
+
// ===========================================================================
|
|
578
|
+
// TOOL: list_my_files — GET /v2/personal/tree
|
|
579
|
+
// Navigate the member's personal PARA vault like a filesystem: Inbox · Projects ·
|
|
580
|
+
// Areas · Resources · Archive · People · Companies. Returns every file as a path.
|
|
581
|
+
// ===========================================================================
|
|
582
|
+
server.tool(
|
|
583
|
+
"list_my_files",
|
|
584
|
+
"List the files in your personal workspace — a PARA knowledge tree (Inbox, Projects, Areas, " +
|
|
585
|
+
"Resources, Archive, People, Companies) of markdown files you own. Use this to SEE what's there " +
|
|
586
|
+
"and navigate it like a filesystem before reading or writing. Returns each file's path " +
|
|
587
|
+
"(e.g. 'projects/acme-rollout/notes.md'); read one with read_my_file, write with save_personal_file.",
|
|
588
|
+
{
|
|
589
|
+
folder: z.string().optional().describe("Optional: only list files under this top-level folder (e.g. 'projects')."),
|
|
590
|
+
},
|
|
591
|
+
async ({ folder }) => {
|
|
592
|
+
const d = await get("/v2/personal/tree");
|
|
593
|
+
let files = d.files ?? [];
|
|
594
|
+
if (folder) files = files.filter((f) => f.folder === String(folder).toLowerCase());
|
|
595
|
+
if (!files.length) return { content: [{ type: "text", text: folder ? `No files in ${folder}/ yet.` : "Your personal workspace is empty." }] };
|
|
596
|
+
const lines = files.map((f) => ` ${f.path}${f.status === "inbox_pending" ? " (pending approval)" : ""}`).join("\n");
|
|
597
|
+
return { content: [{ type: "text", text: `${files.length} file(s):\n${lines}` }] };
|
|
598
|
+
},
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
// ===========================================================================
|
|
602
|
+
// TOOL: read_my_file — GET /v2/personal/file?path=…
|
|
603
|
+
// Read one personal-vault file by its path.
|
|
604
|
+
// ===========================================================================
|
|
605
|
+
server.tool(
|
|
606
|
+
"read_my_file",
|
|
607
|
+
"Read one file from your personal workspace by its path (e.g. 'companies/acme.md' or " +
|
|
608
|
+
"'projects/acme-rollout/notes.md'). Use list_my_files first to find the path. Returns the " +
|
|
609
|
+
"markdown content so you can work with it, then save changes with save_personal_file.",
|
|
610
|
+
{
|
|
611
|
+
path: z.string().describe("The file path: 'folder/[subfolder/]name.md' (folder is one of inbox/projects/areas/resources/archive/people/companies)."),
|
|
612
|
+
},
|
|
613
|
+
async ({ path }) => {
|
|
614
|
+
try {
|
|
615
|
+
const d = await get("/v2/personal/file", { path });
|
|
616
|
+
let text = `# ${d.path}\n\n${d.content || "(empty)"}`;
|
|
617
|
+
const out = (d.outgoing ?? []).filter((l) => l.resolved);
|
|
618
|
+
const back = d.backlinks ?? [];
|
|
619
|
+
if (out.length || back.length) {
|
|
620
|
+
text += `\n\n---\n`;
|
|
621
|
+
if (out.length) text += `Links to: ${out.map((l) => `${l.target}${l.kind.startsWith("relation:") ? ` (${l.kind.slice(9)})` : ""}`).join(", ")}\n`;
|
|
622
|
+
if (back.length) text += `Linked from: ${back.map((l) => l.source).join(", ")}\n`;
|
|
623
|
+
}
|
|
624
|
+
return { content: [{ type: "text", text }] };
|
|
625
|
+
} catch (e) {
|
|
626
|
+
const msg = String(e?.message || "");
|
|
627
|
+
return { content: [{ type: "text", text: msg.includes("404") ? `No file at ${path}. Use list_my_files to see what's there.` : `Couldn't read ${path}.` }] };
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
// ===========================================================================
|
|
633
|
+
// TOOL: create_project — POST /v2/personal/project
|
|
634
|
+
// Scaffold a project folder with the standard sub-structure.
|
|
635
|
+
// ===========================================================================
|
|
636
|
+
server.tool(
|
|
637
|
+
"create_project",
|
|
638
|
+
"Create a new project in your workspace, scaffolded with the standard structure: Context, " +
|
|
639
|
+
"Working Documents, Decisions, People & Companies, Deliverables, Raw Documents, Archive. " +
|
|
640
|
+
"Use this when you start a real piece of work so everything about it has a home. Then write " +
|
|
641
|
+
"files into projects/<name>/<subfolder>/… (e.g. the current status goes in Context).",
|
|
642
|
+
{
|
|
643
|
+
name: z.string().describe("The project name, e.g. 'Acme rollout'."),
|
|
644
|
+
},
|
|
645
|
+
async ({ name }) => {
|
|
646
|
+
const d = await post("/v2/personal/project", { name });
|
|
647
|
+
return { content: [{ type: "text", text: `Created project "${d.project}" at ${d.path}/ with: ${(d.folders || []).join(", ")}.` }] };
|
|
648
|
+
},
|
|
649
|
+
);
|
|
650
|
+
|
|
575
651
|
// ===========================================================================
|
|
576
652
|
// TOOL: propose_vault_file — POST /v2/personal/propose
|
|
577
653
|
// Propose a markdown file into the member's PERSONAL vault. It lands in their
|
|
@@ -585,7 +661,7 @@ export function createServer() {
|
|
|
585
661
|
// same direct personal-vault save (the member owns their vault, so no inbox step).
|
|
586
662
|
server.tool(
|
|
587
663
|
"propose_vault_file",
|
|
588
|
-
"DEPRECATED — use `
|
|
664
|
+
"DEPRECATED — use `save_personal_file`. Saves a markdown file into the member's own private knowledge.",
|
|
589
665
|
{
|
|
590
666
|
folder: z.string().describe("The vault folder (inbox | projects | decisions | companies | people | resources | archive)."),
|
|
591
667
|
name: z.string().describe("The file name, ending in .md."),
|
|
@@ -693,48 +769,49 @@ export function createServer() {
|
|
|
693
769
|
|
|
694
770
|
// ===========================================================================
|
|
695
771
|
// TOOL: propose_company_file — POST /v2/company/pages
|
|
696
|
-
// The WRITE side of the company
|
|
772
|
+
// The WRITE side of the company wiki (search is the read side).
|
|
697
773
|
// Writes an in-app company page directly (no GitHub). Members with a write role
|
|
698
774
|
// publish it live; viewers land it in the company inbox for an admin to approve.
|
|
699
775
|
// ===========================================================================
|
|
776
|
+
const ADD_WIKI_PAGE_SCHEMA = {
|
|
777
|
+
name: z.string().describe("The page name, e.g. 'Refund Policy' or 'Q3 Planning — 2026-08-22'. A '.md' suffix is added if missing."),
|
|
778
|
+
content: z.string().describe("The full markdown content of the page."),
|
|
779
|
+
visibility: z.enum(["owner", "department", "company"]).optional().describe("Who can see the page: 'department' (the team it's filed in — default), 'company' (everyone), or 'owner' (just you)."),
|
|
780
|
+
folder: z.string().optional().describe("Folder to file under, by NAME or path. Top-level folders are fixed — a bare name becomes a project under Projects (e.g. 'Agency' → Projects/Agency); 'Top-Level/Sub' nests under a fixed folder. Created if missing (write role). Use this OR folder_id."),
|
|
781
|
+
subfolder: z.string().optional().describe("Optional subfolder within `folder`, e.g. 'Specs'."),
|
|
782
|
+
folder_id: z.string().optional().describe("Id of an existing company folder to file under (alternative to `folder`). Omit both to use the default folder."),
|
|
783
|
+
};
|
|
784
|
+
const runAddCompanyWikiPage = async ({ name, content, visibility, folder, subfolder, folder_id }) => {
|
|
785
|
+
try {
|
|
786
|
+
const r = await post("/v2/company/pages", { name, content, visibility, folder, subfolder, folder_id });
|
|
787
|
+
const live = r?.page?.status === "live";
|
|
788
|
+
const where = live ? `published live to the company wiki — searchable now` : `sent to the company inbox for an admin to approve`;
|
|
789
|
+
return { content: [{ type: "text", text: `"${r?.page?.name ?? name}" ${where}.` }] };
|
|
790
|
+
} catch (e) {
|
|
791
|
+
const msg = String(e?.message || e);
|
|
792
|
+
if (msg.includes("forbidden") || msg.includes("(403)")) return { content: [{ type: "text", text: `Not saved — you don't have access to write to this company wiki.` }] };
|
|
793
|
+
if (msg.includes("bad_folder")) return { content: [{ type: "text", text: `Not saved — that folder_id isn't a company folder in this workspace. Omit it to use the default folder.` }] };
|
|
794
|
+
throw e;
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
server.tool(
|
|
798
|
+
"add_company_wiki_page",
|
|
799
|
+
"Add a markdown page to the COMPANY WIKI — the shared, declared knowledge (policies, playbooks, " +
|
|
800
|
+
"decisions, SOPs, how-we-work) every member's agent can read via search. A member with a write role " +
|
|
801
|
+
"publishes it live; a viewer's page lands in the company inbox for an admin to approve. File it with " +
|
|
802
|
+
"`folder` — a name or path like 'Projects/Data Centre' (created if missing) — plus optional `subfolder`. " +
|
|
803
|
+
"Set `visibility`: 'department' (the team it's filed in — default), 'company' (everyone), or 'owner' " +
|
|
804
|
+
"(just you); a page in a project inherits the project's Shared-with setting for OTHER departments. To add " +
|
|
805
|
+
"many at once use add_company_wiki_pages. NOT the member's private files (use save_personal_file), NOT a contact note (save_note).",
|
|
806
|
+
ADD_WIKI_PAGE_SCHEMA,
|
|
807
|
+
runAddCompanyWikiPage,
|
|
808
|
+
);
|
|
809
|
+
// DEPRECATED alias → add_company_wiki_page. Kept so existing agents keep working.
|
|
700
810
|
server.tool(
|
|
701
811
|
"propose_company_file",
|
|
702
|
-
"
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
"it live immediately; a viewer's page lands in the company inbox for an admin to approve. File " +
|
|
706
|
-
"it with `folder` — a name or path like 'Projects/Data Centre' (created if missing) — plus an " +
|
|
707
|
-
"optional `subfolder`. Set `visibility` to scope who sees it: 'department' (the team it's filed " +
|
|
708
|
-
"in — the default), 'company' (everyone), or 'owner' (just you). A page in a project inherits the " +
|
|
709
|
-
"project's Shared-with setting for OTHER departments. To add many pages at once, use " +
|
|
710
|
-
"propose_company_files. NOT the member's private vault (use save_to_vault), NOT a contact note (save_note).",
|
|
711
|
-
{
|
|
712
|
-
name: z.string().describe("The page name, e.g. 'Refund Policy' or 'Q3 Planning — 2026-08-22'. A '.md' suffix is added if missing."),
|
|
713
|
-
content: z.string().describe("The full markdown content of the page."),
|
|
714
|
-
visibility: z.enum(["owner", "department", "company"]).optional().describe("Who can see the page: 'department' (the team it's filed in — default), 'company' (everyone), or 'owner' (just you)."),
|
|
715
|
-
folder: z.string().optional().describe("Folder to file under, by NAME or path. Top-level folders are fixed — a bare name becomes a project under Projects (e.g. 'Agency' → Projects/Agency); 'Top-Level/Sub' nests under a fixed folder. Created if missing (write role). Use this OR folder_id."),
|
|
716
|
-
subfolder: z.string().optional().describe("Optional subfolder within `folder`, e.g. 'Specs'."),
|
|
717
|
-
folder_id: z.string().optional().describe("Id of an existing company folder to file under (alternative to `folder`). Omit both to use the default 'wiki' folder."),
|
|
718
|
-
},
|
|
719
|
-
async ({ name, content, visibility, folder, subfolder, folder_id }) => {
|
|
720
|
-
try {
|
|
721
|
-
const r = await post("/v2/company/pages", { name, content, visibility, folder, subfolder, folder_id });
|
|
722
|
-
const live = r?.page?.status === "live";
|
|
723
|
-
const where = live
|
|
724
|
-
? `published live to the company vault — searchable now`
|
|
725
|
-
: `sent to the company inbox for an admin to approve`;
|
|
726
|
-
return { content: [{ type: "text", text: `"${r?.page?.name ?? name}" ${where}.` }] };
|
|
727
|
-
} catch (e) {
|
|
728
|
-
const msg = String(e?.message || e);
|
|
729
|
-
if (msg.includes("forbidden") || msg.includes("(403)")) {
|
|
730
|
-
return { content: [{ type: "text", text: `Not saved — you don't have access to write to this company vault.` }] };
|
|
731
|
-
}
|
|
732
|
-
if (msg.includes("bad_folder")) {
|
|
733
|
-
return { content: [{ type: "text", text: `Not saved — that folder_id isn't a company folder in this workspace. Omit it to use the default 'wiki' folder.` }] };
|
|
734
|
-
}
|
|
735
|
-
throw e;
|
|
736
|
-
}
|
|
737
|
-
},
|
|
812
|
+
"DEPRECATED — use `add_company_wiki_page`. Adds a markdown page to the shared company wiki.",
|
|
813
|
+
ADD_WIKI_PAGE_SCHEMA,
|
|
814
|
+
runAddCompanyWikiPage,
|
|
738
815
|
);
|
|
739
816
|
|
|
740
817
|
|
|
@@ -742,65 +819,83 @@ export function createServer() {
|
|
|
742
819
|
// TOOL: create_folder — POST /v2/company/folders { path }
|
|
743
820
|
// Create a folder / nested path in the shared company tree so pages can be filed into it.
|
|
744
821
|
// ===========================================================================
|
|
822
|
+
const CREATE_WIKI_FOLDER_SCHEMA = { 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.") };
|
|
823
|
+
const runCreateCompanyWikiFolder = async ({ path }) => {
|
|
824
|
+
try {
|
|
825
|
+
const r = await post("/v2/company/folders", { path });
|
|
826
|
+
const made = r?.created?.length ? ` (created ${r.created.join(" / ")})` : " (already existed)";
|
|
827
|
+
return { content: [{ type: "text", text: `Folder "${r?.folder?.name ?? path}" ready${made}. folder_id: ${r?.folder?.id ?? "?"}` }] };
|
|
828
|
+
} catch (e) {
|
|
829
|
+
const msg = String(e?.message || e);
|
|
830
|
+
if (msg.includes("read_only") || msg.includes("(403)")) return { content: [{ type: "text", text: "You don't have a write role in this company wiki, so you can't create folders here." }] };
|
|
831
|
+
if (msg.includes("bad_path")) return { content: [{ type: "text", text: "Couldn't create that path — give a folder name or 'Parent/Child' path." }] };
|
|
832
|
+
throw e;
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
server.tool(
|
|
836
|
+
"create_company_wiki_folder",
|
|
837
|
+
"Create a folder in the COMPANY WIKI tree so you can file pages into it. The TOP-LEVEL folders are FIXED " +
|
|
838
|
+
"(Company · Projects · Decisions · Companies · People · Raw Documents · Archive) — you cannot add new ones. " +
|
|
839
|
+
"A bare name (or a path that doesn't start with one of those) becomes a PROJECT under Projects: 'Agency' → " +
|
|
840
|
+
"Projects/Agency. To nest under a specific top-level folder, start the path with it, e.g. 'Company/Policies' " +
|
|
841
|
+
"or 'Projects/Data Centre/Specs'. A project gets a 'Shared with' control the Ambassador uses to grant " +
|
|
842
|
+
"departments. A write role is required; the folder appears immediately. Then add pages with " +
|
|
843
|
+
"add_company_wiki_page / add_company_wiki_pages using the same path. Returns the folder id.",
|
|
844
|
+
CREATE_WIKI_FOLDER_SCHEMA,
|
|
845
|
+
runCreateCompanyWikiFolder,
|
|
846
|
+
);
|
|
847
|
+
// DEPRECATED alias → create_company_wiki_folder.
|
|
745
848
|
server.tool(
|
|
746
849
|
"create_folder",
|
|
747
|
-
"
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
"Projects: 'Agency' → Projects/Agency. To nest under a specific top-level folder, start the path with it, " +
|
|
751
|
-
"e.g. 'Company/Policies' or 'Projects/Data Centre/Specs'. A project gets a 'Shared with' control the " +
|
|
752
|
-
"Ambassador uses to grant departments. A write role is required; the folder appears immediately. Then add " +
|
|
753
|
-
"pages with propose_company_file / propose_company_files using the same path. Returns the folder id.",
|
|
754
|
-
{ 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.") },
|
|
755
|
-
async ({ path }) => {
|
|
756
|
-
try {
|
|
757
|
-
const r = await post("/v2/company/folders", { path });
|
|
758
|
-
const made = r?.created?.length ? ` (created ${r.created.join(" / ")})` : " (already existed)";
|
|
759
|
-
return { content: [{ type: "text", text: `Folder "${r?.folder?.name ?? path}" ready${made}. folder_id: ${r?.folder?.id ?? "?"}` }] };
|
|
760
|
-
} catch (e) {
|
|
761
|
-
const msg = String(e?.message || e);
|
|
762
|
-
if (msg.includes("read_only") || msg.includes("(403)")) return { content: [{ type: "text", text: "You don't have a write role in this company vault, so you can't create folders here." }] };
|
|
763
|
-
if (msg.includes("bad_path")) return { content: [{ type: "text", text: "Couldn't create that path — give a folder name or 'Parent/Child' path." }] };
|
|
764
|
-
throw e;
|
|
765
|
-
}
|
|
766
|
-
},
|
|
850
|
+
"DEPRECATED — use `create_company_wiki_folder`. Creates a folder in the shared company wiki tree.",
|
|
851
|
+
CREATE_WIKI_FOLDER_SCHEMA,
|
|
852
|
+
runCreateCompanyWikiFolder,
|
|
767
853
|
);
|
|
768
854
|
|
|
769
855
|
// ===========================================================================
|
|
770
856
|
// TOOL: propose_company_files — POST /v2/company/pages/batch
|
|
771
857
|
// Bulk-add many pages into ONE company folder (resolved/created once).
|
|
772
858
|
// ===========================================================================
|
|
859
|
+
const ADD_WIKI_PAGES_SCHEMA = {
|
|
860
|
+
folder: z.string().optional().describe("Folder to file all pages under. Top-level is fixed — a bare name becomes a project under Projects (e.g. 'Agency' → Projects/Agency); 'Top-Level/Sub' nests under a fixed folder. Created if missing."),
|
|
861
|
+
subfolder: z.string().optional().describe("Optional subfolder within `folder`."),
|
|
862
|
+
folder_id: z.string().optional().describe("Id of an existing folder (alternative to `folder`)."),
|
|
863
|
+
visibility: z.enum(["owner", "department", "company"]).optional().describe("Default visibility for the pages (a page can override its own). Default 'department'."),
|
|
864
|
+
pages: z.array(z.object({
|
|
865
|
+
name: z.string().describe("Page name."),
|
|
866
|
+
content: z.string().optional().describe("Markdown content."),
|
|
867
|
+
visibility: z.enum(["owner", "department", "company"]).optional().describe("Optional per-page visibility."),
|
|
868
|
+
})).describe("The pages to add: each { name, content, visibility? }."),
|
|
869
|
+
};
|
|
870
|
+
const runAddCompanyWikiPages = async ({ folder, subfolder, folder_id, visibility, pages }) => {
|
|
871
|
+
try {
|
|
872
|
+
const r = await post("/v2/company/pages/batch", { folder, subfolder, folder_id, visibility, pages });
|
|
873
|
+
const where = r?.status === "live" ? "published live — searchable now" : "sent to the company inbox for an admin to approve";
|
|
874
|
+
return { content: [{ type: "text", text: `${r?.count ?? 0} page(s) ${where} in "${r?.folder ?? folder ?? "wiki"}".` }] };
|
|
875
|
+
} catch (e) {
|
|
876
|
+
const msg = String(e?.message || e);
|
|
877
|
+
if (msg.includes("forbidden") || msg.includes("(403)")) return { content: [{ type: "text", text: "You don't have access to write to this company wiki." }] };
|
|
878
|
+
if (msg.includes("bad_folder")) return { content: [{ type: "text", text: "That folder path couldn't be resolved or created — check the name, or pass folder_id." }] };
|
|
879
|
+
if (msg.includes("too_many")) return { content: [{ type: "text", text: "Too many pages — max 50 per call. Split into batches." }] };
|
|
880
|
+
if (msg.includes("pages_required")) return { content: [{ type: "text", text: "Give at least one page ({ name, content })." }] };
|
|
881
|
+
throw e;
|
|
882
|
+
}
|
|
883
|
+
};
|
|
773
884
|
server.tool(
|
|
774
|
-
"
|
|
775
|
-
"Add MANY markdown pages to the
|
|
776
|
-
"
|
|
777
|
-
"
|
|
885
|
+
"add_company_wiki_pages",
|
|
886
|
+
"Add MANY markdown pages to the COMPANY WIKI in ONE call — all into the SAME folder (resolved or created " +
|
|
887
|
+
"once). Use it to bulk-file: 'put these 10 docs in Data Centre'. Same rules as add_company_wiki_page: a " +
|
|
888
|
+
"write role publishes them live; a viewer sends the whole batch to the company inbox. Name the target with " +
|
|
778
889
|
"`folder` (a path, created if missing) + optional `subfolder`, or an existing `folder_id`. Max 50 pages.",
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
})).describe("The pages to add: each { name, content, visibility? }."),
|
|
789
|
-
},
|
|
790
|
-
async ({ folder, subfolder, folder_id, visibility, pages }) => {
|
|
791
|
-
try {
|
|
792
|
-
const r = await post("/v2/company/pages/batch", { folder, subfolder, folder_id, visibility, pages });
|
|
793
|
-
const where = r?.status === "live" ? "published live — searchable now" : "sent to the company inbox for an admin to approve";
|
|
794
|
-
return { content: [{ type: "text", text: `${r?.count ?? 0} page(s) ${where} in "${r?.folder ?? folder ?? "wiki"}".` }] };
|
|
795
|
-
} catch (e) {
|
|
796
|
-
const msg = String(e?.message || e);
|
|
797
|
-
if (msg.includes("forbidden") || msg.includes("(403)")) return { content: [{ type: "text", text: "You don't have access to write to this company vault." }] };
|
|
798
|
-
if (msg.includes("bad_folder")) return { content: [{ type: "text", text: "That folder path couldn't be resolved or created — check the name, or pass folder_id." }] };
|
|
799
|
-
if (msg.includes("too_many")) return { content: [{ type: "text", text: "Too many pages — max 50 per call. Split into batches." }] };
|
|
800
|
-
if (msg.includes("pages_required")) return { content: [{ type: "text", text: "Give at least one page ({ name, content })." }] };
|
|
801
|
-
throw e;
|
|
802
|
-
}
|
|
803
|
-
},
|
|
890
|
+
ADD_WIKI_PAGES_SCHEMA,
|
|
891
|
+
runAddCompanyWikiPages,
|
|
892
|
+
);
|
|
893
|
+
// DEPRECATED alias → add_company_wiki_pages.
|
|
894
|
+
server.tool(
|
|
895
|
+
"propose_company_files",
|
|
896
|
+
"DEPRECATED — use `add_company_wiki_pages`. Bulk-adds pages to the shared company wiki.",
|
|
897
|
+
ADD_WIKI_PAGES_SCHEMA,
|
|
898
|
+
runAddCompanyWikiPages,
|
|
804
899
|
);
|
|
805
900
|
|
|
806
901
|
// ===========================================================================
|
|
@@ -847,35 +942,43 @@ export function createServer() {
|
|
|
847
942
|
// TOOL: save_to_vault — POST /v2/personal/files
|
|
848
943
|
// Save a file into the MEMBER'S OWN private vault folder. Extracts a PDF/DOCX to text.
|
|
849
944
|
// ===========================================================================
|
|
945
|
+
const SAVE_PERSONAL_SCHEMA = {
|
|
946
|
+
folder: z.string().describe("The personal folder: inbox | projects | decisions | companies | people | raw documents | archive."),
|
|
947
|
+
name: z.string().describe("The file name, e.g. 'Q3 Plan.md' or 'vendor-contract.pdf'."),
|
|
948
|
+
content: z.string().optional().describe("The text/markdown content, when you already have it as text."),
|
|
949
|
+
file: z.string().optional().describe("A PDF or DOCX as base64 (a data: URL is fine). It's extracted to text automatically. Provide this OR content."),
|
|
950
|
+
mime: z.string().optional().describe("The file's MIME type, e.g. 'application/pdf' or the docx type — helps pick the right extractor."),
|
|
951
|
+
subfolder: z.string().optional().describe("Optional subfolder within the folder."),
|
|
952
|
+
};
|
|
953
|
+
const runSavePersonalFile = async ({ folder, name, content, file, mime, subfolder }) => {
|
|
954
|
+
try {
|
|
955
|
+
const r = await post("/v2/personal/files", { folder, name, content, file_base64: file, mime, subfolder });
|
|
956
|
+
return { content: [{ type: "text", text: `"${name}" is waiting in your Inbox to approve — once you do, it files into ${r?.folder || folder} and becomes searchable.` }] };
|
|
957
|
+
} catch (e) {
|
|
958
|
+
const msg = String(e?.message || e);
|
|
959
|
+
if (msg.includes("no_text_extracted")) return { content: [{ type: "text", text: `Couldn't read any text from that file — a scanned/image-only PDF has no extractable text. Text, markdown, or a text-based PDF/DOCX works.` }] };
|
|
960
|
+
if (msg.includes("content_or_file_required")) return { content: [{ type: "text", text: `Nothing to save — pass either text content or a base64 file.` }] };
|
|
961
|
+
throw e;
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
server.tool(
|
|
965
|
+
"save_personal_file",
|
|
966
|
+
"Save a file into the MEMBER'S OWN personal knowledge (their private files — 'My Knowledge'). It lands in " +
|
|
967
|
+
"their INBOX for review with the target folder you name remembered (inbox, projects, decisions, companies, " +
|
|
968
|
+
"people, raw documents, archive); the member approves it in the app, which files it and makes it searchable. " +
|
|
969
|
+
"Nothing enters their knowledge without their approval. Use it when the member asks you to keep or file " +
|
|
970
|
+
"something — 'save this to raw documents', 'put this PDF in projects'. Pass markdown/text as `content`, OR a " +
|
|
971
|
+
"PDF/DOCX as base64 in `file` (auto-extracted). PRIVATE to the member. NOT the company wiki (use " +
|
|
972
|
+
"add_company_wiki_page), NOT a note on a contact (use save_note).",
|
|
973
|
+
SAVE_PERSONAL_SCHEMA,
|
|
974
|
+
runSavePersonalFile,
|
|
975
|
+
);
|
|
976
|
+
// DEPRECATED alias → save_personal_file.
|
|
850
977
|
server.tool(
|
|
851
978
|
"save_to_vault",
|
|
852
|
-
"
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
"and makes it searchable. Nothing enters their knowledge without their approval. Use it when the " +
|
|
856
|
-
"member asks you to keep or file something — 'save this to resources', 'put this PDF in projects'. " +
|
|
857
|
-
"Pass markdown/text as `content`, OR a PDF/DOCX as base64 in `file` (extracted to text automatically). " +
|
|
858
|
-
"PRIVATE to the member. NOT the shared company vault (use propose_company_file), NOT a note on a " +
|
|
859
|
-
"contact (use save_note).",
|
|
860
|
-
{
|
|
861
|
-
folder: z.string().describe("The vault folder: inbox | projects | decisions | companies | people | resources | archive | thoughts."),
|
|
862
|
-
name: z.string().describe("The file name, e.g. 'Q3 Plan.md' or 'vendor-contract.pdf'."),
|
|
863
|
-
content: z.string().optional().describe("The text/markdown content, when you already have it as text."),
|
|
864
|
-
file: z.string().optional().describe("A PDF or DOCX as base64 (a data: URL is fine). It's extracted to text automatically. Provide this OR content."),
|
|
865
|
-
mime: z.string().optional().describe("The file's MIME type, e.g. 'application/pdf' or the docx type — helps pick the right extractor."),
|
|
866
|
-
subfolder: z.string().optional().describe("Optional subfolder within the folder."),
|
|
867
|
-
},
|
|
868
|
-
async ({ folder, name, content, file, mime, subfolder }) => {
|
|
869
|
-
try {
|
|
870
|
-
const r = await post("/v2/personal/files", { folder, name, content, file_base64: file, mime, subfolder });
|
|
871
|
-
return { content: [{ type: "text", text: `"${name}" is waiting in your Inbox to approve — once you do, it files into ${r?.folder || folder} and becomes searchable.` }] };
|
|
872
|
-
} catch (e) {
|
|
873
|
-
const msg = String(e?.message || e);
|
|
874
|
-
if (msg.includes("no_text_extracted")) return { content: [{ type: "text", text: `Couldn't read any text from that file — a scanned/image-only PDF has no extractable text. Text, markdown, or a text-based PDF/DOCX works.` }] };
|
|
875
|
-
if (msg.includes("content_or_file_required")) return { content: [{ type: "text", text: `Nothing to save — pass either text content or a base64 file.` }] };
|
|
876
|
-
throw e;
|
|
877
|
-
}
|
|
878
|
-
}
|
|
979
|
+
"DEPRECATED — use `save_personal_file`. Saves a file into the member's own private knowledge.",
|
|
980
|
+
SAVE_PERSONAL_SCHEMA,
|
|
981
|
+
runSavePersonalFile,
|
|
879
982
|
);
|
|
880
983
|
|
|
881
984
|
// ===========================================================================
|
|
@@ -884,32 +987,41 @@ export function createServer() {
|
|
|
884
987
|
// then replaces/appends + re-embeds. save_to_vault is for NEW files (→ Inbox);
|
|
885
988
|
// this edits one that's already live.
|
|
886
989
|
// ===========================================================================
|
|
990
|
+
const UPDATE_PERSONAL_SCHEMA = {
|
|
991
|
+
file_id: z.string().optional().describe("The file's id (from a search/save result). Provide this OR path."),
|
|
992
|
+
path: z.string().optional().describe("The file's path, e.g. 'raw documents/Edda Architecture.md'. Provide this OR file_id."),
|
|
993
|
+
content: z.string().describe("The new markdown content. Replaces the file's content unless append is true."),
|
|
994
|
+
append: z.boolean().optional().describe("If true, append this to the end of the file instead of replacing it."),
|
|
995
|
+
};
|
|
996
|
+
const runUpdatePersonalFile = async ({ file_id, path, content, append }) => {
|
|
997
|
+
try {
|
|
998
|
+
const r = await post("/v2/personal/files/update", { file_id, path, content, append });
|
|
999
|
+
return { content: [{ type: "text", text: r?.note || "Updated the file." }] };
|
|
1000
|
+
} catch (e) {
|
|
1001
|
+
const msg = String(e?.message || e);
|
|
1002
|
+
if (msg.includes("not_found")) return { content: [{ type: "text", text: "Couldn't find that file — check the file_id or path (folder/name.md)." }] };
|
|
1003
|
+
if (msg.includes("file_id_or_path_required")) return { content: [{ type: "text", text: "Tell me which file to edit — pass file_id or path." }] };
|
|
1004
|
+
if (msg.includes("content_required")) return { content: [{ type: "text", text: "Give me the new content to write." }] };
|
|
1005
|
+
throw e;
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
server.tool(
|
|
1009
|
+
"update_personal_file",
|
|
1010
|
+
"Edit an EXISTING file in the member's own personal knowledge — revise it or append to it. Identify the file " +
|
|
1011
|
+
"by `file_id` (from a search or save result) or by `path` ('folder/[subfolder/]name.md'). The previous " +
|
|
1012
|
+
"content is saved to version history FIRST (never lost), then your new content replaces it — or is added to " +
|
|
1013
|
+
"the end with append:true — and the file is re-embedded; the member sees a fresh 'updated' time. Use this to " +
|
|
1014
|
+
"keep a living document current: a rolling brief, a spec, a running log. For a NEW file use save_personal_file " +
|
|
1015
|
+
"(which lands in the Inbox for approval); this edits one that already exists and is live.",
|
|
1016
|
+
UPDATE_PERSONAL_SCHEMA,
|
|
1017
|
+
runUpdatePersonalFile,
|
|
1018
|
+
);
|
|
1019
|
+
// DEPRECATED alias → update_personal_file.
|
|
887
1020
|
server.tool(
|
|
888
1021
|
"update_vault_file",
|
|
889
|
-
"
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
"to the end with append:true — and the file is re-embedded; the member sees a fresh 'updated' time. Use " +
|
|
893
|
-
"this to keep a living document current: a rolling brief, a spec, a running log. For a NEW file use " +
|
|
894
|
-
"save_to_vault (which lands in the Inbox for approval); this edits one that already exists and is live.",
|
|
895
|
-
{
|
|
896
|
-
file_id: z.string().optional().describe("The file's id (from a search/save result). Provide this OR path."),
|
|
897
|
-
path: z.string().optional().describe("The file's vault path, e.g. 'resources/Edda Architecture.md'. Provide this OR file_id."),
|
|
898
|
-
content: z.string().describe("The new markdown content. Replaces the file's content unless append is true."),
|
|
899
|
-
append: z.boolean().optional().describe("If true, append this to the end of the file instead of replacing it."),
|
|
900
|
-
},
|
|
901
|
-
async ({ file_id, path, content, append }) => {
|
|
902
|
-
try {
|
|
903
|
-
const r = await post("/v2/personal/files/update", { file_id, path, content, append });
|
|
904
|
-
return { content: [{ type: "text", text: r?.note || "Updated the file." }] };
|
|
905
|
-
} catch (e) {
|
|
906
|
-
const msg = String(e?.message || e);
|
|
907
|
-
if (msg.includes("not_found")) return { content: [{ type: "text", text: "Couldn't find that file — check the file_id or path (folder/name.md)." }] };
|
|
908
|
-
if (msg.includes("file_id_or_path_required")) return { content: [{ type: "text", text: "Tell me which file to edit — pass file_id or path." }] };
|
|
909
|
-
if (msg.includes("content_required")) return { content: [{ type: "text", text: "Give me the new content to write." }] };
|
|
910
|
-
throw e;
|
|
911
|
-
}
|
|
912
|
-
}
|
|
1022
|
+
"DEPRECATED — use `update_personal_file`. Edits/append an existing file in the member's own knowledge.",
|
|
1023
|
+
UPDATE_PERSONAL_SCHEMA,
|
|
1024
|
+
runUpdatePersonalFile,
|
|
913
1025
|
);
|
|
914
1026
|
|
|
915
1027
|
server.tool(
|