@edda-business/mcp 0.54.0 → 0.56.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 +168 -129
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edda-business/mcp",
3
- "version": "0.54.0",
3
+ "version": "0.56.0",
4
4
  "description": "Edda — the company data layer for AI agents.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/server.js CHANGED
@@ -10,19 +10,23 @@
10
10
  * sees raw rows — it gets engineered, epistemics-tagged context. It never
11
11
  * "updates" — it records observations; Edda derives.
12
12
  *
13
- * Tools, by group:
14
- * READ get_context · get_account · query · attention · verify ·
15
- * search_notes · search_company_knowledge · search_my_vault
16
- * WRITE save_note · propose_vault_file · propose_company_file · merge_contacts
17
- * CORRECT unmerge_contacts
18
- * RUN list_integrations · connect_integration
13
+ * Tools, by group (v0.56 — consolidated, Cerebras-style):
14
+ * RETRIEVE search (unified, scope=all|company|personal|notes) · get_context ·
15
+ * get_account · who_knows · query · attention · verify
16
+ * WRITE save_note · save_to_vault · propose_company_file
17
+ * FIX merge_contacts (action=merge|split)
18
+ * RUN list_integrations · connect_integration
19
+ *
20
+ * Deprecated aliases kept for back-compat (forward to the above, removed after a window):
21
+ * search_company_knowledge · search_my_vault · search_notes → search
22
+ * propose_vault_file → save_to_vault unmerge_contacts → merge_contacts(action:split)
19
23
  */
20
24
 
21
25
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
26
  import { z } from "zod";
23
27
  import { get, post, del } from "./client.js";
24
28
 
25
- export const SERVER_VERSION = "0.54.0";
29
+ export const SERVER_VERSION = "0.56.0";
26
30
 
27
31
  // ─── helpers ──────────────────────────────────────────────────────────────────
28
32
 
@@ -146,7 +150,8 @@ export function createServer() {
146
150
  lines.push("DOCUMENTS (notes & meeting records — use search_notes to search their content):");
147
151
  for (const d of ctx.documents) {
148
152
  const when = d.date ? ` [${relAge(d.date)}]` : "";
149
- lines.push(` ${d.type.replace(/_/g, " ")}${d.title ? ` · ${d.title}` : ""}${when}`);
153
+ const from = d.source_member ? ` · from ${d.source_member}` : "";
154
+ lines.push(` ${d.type.replace(/_/g, " ")}${d.title ? ` · ${d.title}` : ""}${from}${when}`);
150
155
  if (d.snippet) lines.push(` ${d.snippet}`);
151
156
  }
152
157
  lines.push("");
@@ -306,18 +311,45 @@ export function createServer() {
306
311
  // ===========================================================================
307
312
  server.tool(
308
313
  "merge_contacts",
309
- "Merge two duplicate records for the SAME person into one account. Use when the same human exists " +
310
- "twice e.g. one record from a LinkedIn connection (no email) and one from a Cal.com booking (email, " +
311
- "truncated name) that never got linked. Pass `keep` (the survivor) and `drop` (the duplicate to fold in); " +
312
- "each may be an email, LinkedIn URL, entity UUID, or name. Lossless the duplicate's identifiers (a second " +
313
- "email, a LinkedIn URL) re-attach to the survivor, so a future match on EITHER resolves to the one account " +
314
- "and reversible. If a name matches several people you'll get candidates: confirm the survivor with the user, " +
315
- "then re-call with the chosen entity ids. Prefer passing the keep that already has the most history.",
314
+ "Reconcile identity — MERGE two duplicate records for the same person into one, or SPLIT a wrongly-merged " +
315
+ "record back out. Set `action`: 'merge' (default) folds `drop` into the survivor `keep`; 'split' reverses a " +
316
+ "merge (pass the merged-away id as `drop`, or just `keep` to undo the most recent merge into it). Merge is " +
317
+ "lossless + reversible the duplicate's identifiers re-attach to the survivor so a future match on either " +
318
+ "resolves to one account. If a name matches several people you'll get candidates to disambiguate. Use merge " +
319
+ "when the same human exists twice (a LinkedIn connection with no email + a Cal.com booking that never linked); " +
320
+ "use split when two DIFFERENT people were merged by mistake.",
316
321
  {
317
- keep: z.string().describe("The survivor to keep email, LinkedIn URL, entity UUID, or name."),
318
- drop: z.string().describe("The duplicate to fold into keep — email, LinkedIn URL, entity UUID, or name."),
322
+ action: z.enum(["merge", "split"]).optional().describe("'merge' (default) folds drop into keep; 'split' reverses a prior merge."),
323
+ keep: z.string().optional().describe("The survivor — email, LinkedIn URL, entity UUID, or name. For split, the account to undo the most recent merge into (if drop is omitted)."),
324
+ drop: z.string().optional().describe("For merge: the duplicate to fold into keep. For split: the merged-away entity id from the merge result."),
319
325
  },
320
- async ({ keep, drop }) => {
326
+ async ({ action, keep, drop }) => {
327
+ // SPLIT — reverse a prior merge (was unmerge_contacts).
328
+ if (action === "split") {
329
+ if (!drop && !keep) return { content: [{ type: "text", text: "To split, give the merged-away id as `drop`, or the `keep` survivor whose last merge to undo." }] };
330
+ try {
331
+ const u = await post("/v2/accounts/unmerge", { drop_id: drop, keep });
332
+ if (u.status === "ambiguous") {
333
+ const opts = (u.candidates ?? []).map(c => ` • ${c.name ?? "(unnamed)"} [${c.entity_id}]`).join("\n");
334
+ return { content: [{ type: "text", text: `"${keep}" matches several people. Re-call merge_contacts action:split with one of these entity ids as keep:\n${opts}` }] };
335
+ }
336
+ const lines = [
337
+ `Split — ${u.drop_id} is its own account again.`,
338
+ ` identifiers restored: ${u.identifiers}, claims: ${u.claims}, observations: ${u.observations}, relationships: ${u.relationships}`,
339
+ u.contact_restored ? ` the contact record was recreated.` : null,
340
+ ].filter(Boolean);
341
+ return { content: [{ type: "text", text: lines.join("\n") }] };
342
+ } catch (e) {
343
+ const msg = /not_reversible/.test(e.message) ? "That merge can't be reversed — it predates reversible-merge tracking, or it was already split."
344
+ : /no_reversible_merge/.test(e.message) ? "No un-reversed merge on that survivor to undo."
345
+ : /entity_not_found/.test(e.message) ? "Couldn't find that survivor — check the keep identifier."
346
+ : `Couldn't split: ${e.message}`;
347
+ return { content: [{ type: "text", text: msg }] };
348
+ }
349
+ }
350
+
351
+ // MERGE (default).
352
+ if (!keep || !drop) return { content: [{ type: "text", text: "To merge, pass both `keep` (survivor) and `drop` (duplicate)." }] };
321
353
  const r = await post("/v2/accounts/merge", { keep, drop });
322
354
 
323
355
  if (r.status === "ambiguous") {
@@ -337,21 +369,17 @@ export function createServer() {
337
369
  (r.relationships_repointed || r.relationships_removed)
338
370
  ? ` relationships: ${r.relationships_repointed} re-pointed, ${r.relationships_removed} pruned` : null,
339
371
  moved ? ` re-pointed: ${moved}` : null,
340
- `Reversible: if this was wrong, unmerge_contacts with drop_id "${r.drop_id}" puts it all back.`,
372
+ `Reversible: if this was wrong, merge_contacts action:split drop "${r.drop_id}" puts it all back.`,
341
373
  ].filter(Boolean);
342
374
  return { content: [{ type: "text", text: lines.join("\n") }] };
343
375
  }
344
376
  );
345
377
 
378
+ // DEPRECATED — use merge_contacts with action:'split'. Kept so existing agents keep working.
346
379
  server.tool(
347
380
  "unmerge_contacts",
348
- "REVERSE a merge split a wrongly-merged duplicate back out into its own account. Identify the " +
349
- "merge to undo either by `drop_id` (the tombstone's id, exactly as merge_contacts reported it) or by " +
350
- "`keep` (an identifier for the survivor — undoes the MOST RECENT merge into it). Every re-pointed " +
351
- "identifier, claim, observation, relationship and the record itself go back where they were. " +
352
- "Forward-only: it can only reverse merges made after reversible-merge tracking existed — an older " +
353
- "merge returns a clear 'not reversible'. Use this when two DIFFERENT people were merged by mistake; " +
354
- "it is not for editing a correctly-merged account.",
381
+ "DEPRECATEDuse `merge_contacts` with action:'split'. Reverses a merge, splitting a wrongly-merged " +
382
+ "record back into its own account (by `drop_id`, or by `keep` to undo the most recent merge into it).",
355
383
  {
356
384
  drop_id: z.string().optional().describe("The merged-away entity's id, from the merge_contacts result. Provide this OR keep."),
357
385
  keep: z.string().optional().describe("The survivor (email, LinkedIn URL, entity UUID, or name) — undoes the most recent un-reversed merge into it."),
@@ -569,134 +597,111 @@ export function createServer() {
569
597
  // consent. Distinct from `save_note` (which attaches a document to a CONTACT's
570
598
  // record) — this is the member's private vault, not an account.
571
599
  // ===========================================================================
600
+ // DEPRECATED — use save_to_vault. Kept so existing agents keep working; forwards to the
601
+ // same direct personal-vault save (the member owns their vault, so no inbox step).
572
602
  server.tool(
573
603
  "propose_vault_file",
574
- "Propose a markdown file into the member's personal vault (their private notes, not an " +
575
- "account). It lands in their INBOX as a proposal — the member approves it before it's filed " +
576
- "into a folder and synced to their Git; nothing is written without their approval. Use this to " +
577
- "draft a thought, a decision record, a brief, or content for the member to review and keep. " +
578
- "Pick the destination folder. This is NOT for notes on a contact (use save_note) and NOT for the " +
579
- "GTM profile (use sync_icp).",
604
+ "DEPRECATED — use `save_to_vault`. Saves a markdown file into the member's own private vault folder.",
580
605
  {
581
- folder: z.enum(["inbox", "projects", "decisions", "accounts", "people", "resources", "archive"])
582
- .describe("Where it files once approved. ROUTING: accounts = a note or brief about a specific COMPANY (subfolder = the account). people = a note or brief about a specific PERSON (subfolder = the person). projects = ongoing/recurring analysis or an initiative, WITH a subfolder (pipeline/funnel review -> projects 'pipeline'; campaign analysis -> projects 'campaigns'; an initiative -> projects '<name>'). decisions = a decision. resources = reference material, research, or anything to publish/send (post, newsletter, outbound copy, write-up). archive = old or inactive material. Don't pick 'inbox' — that's where it lands to await approval."),
583
- name: z.string().describe("The file name, ending in .md, for what it is plus the date, e.g. 'Pipeline Review — 2026-08-12.md'."),
584
- content: z.string().describe("The full markdown content of the file."),
585
- subfolder: z.string().optional().describe("REQUIRED for 'projects' — the area: 'pipeline', 'campaigns', or the initiative name. Leave empty for 'briefs' (flat) and other folders."),
606
+ folder: z.string().describe("The vault folder (inbox | projects | decisions | companies | people | resources | archive)."),
607
+ name: z.string().describe("The file name, ending in .md."),
608
+ content: z.string().describe("The full markdown content."),
609
+ subfolder: z.string().optional().describe("Optional subfolder within the folder."),
586
610
  },
587
611
  async ({ folder, name, content, subfolder }) => {
588
- await post("/v2/personal/propose", { folder, name, content, subfolder });
589
- return { content: [{ type: "text", text: `Proposed "${name}" into ${folder}. It's waiting in the member's inbox for approval.` }] };
612
+ const r = await post("/v2/personal/files", { folder, name, content, subfolder });
613
+ return { content: [{ type: "text", text: `Saved "${name}" to your ${r?.folder || folder} vault it's searchable now.` }] };
590
614
  },
591
615
  );
592
616
 
593
617
  // ===========================================================================
594
- // TOOL: search_notes — POST /v2/notes/search
595
- // Semantic search over saved notes & documents (briefs, transcripts, notes).
596
- // The retrieval counterpart to save_note pull relevant document content
597
- // instead of dumping whole documents into context.
618
+ // TOOL: search — POST /v2/search (unified; replaces the three scoped searches)
619
+ // One retrieval surface across company knowledge + personal vault + notes, ACL-
620
+ // filtered per layer and fused best-first. `scope` narrows it. Cerebras-style:
621
+ // one search, the agent orchestrates; get_context is the synthesized counterpart.
598
622
  // ===========================================================================
623
+ const runSearch = async ({ question, scope, limit, folder }) => {
624
+ const r = await post("/v2/search", { question, scope, limit, folder });
625
+ if (!r.documents?.length) {
626
+ return { content: [{ type: "text", text: `Nothing matched "${question}"${scope && scope !== "all" ? ` in ${scope}` : ""}.` }] };
627
+ }
628
+ const lines = [`Results for "${question}"${scope && scope !== "all" ? ` (${scope})` : ""}:`, ""];
629
+ for (const d of r.documents) {
630
+ const tag = d.scope === "company" ? (d.kind === "derived" ? "[company·derived]" : "[company]")
631
+ : d.scope === "personal" ? "[my vault]" : "[note]";
632
+ const where = d.ref ? `${d.ref} · ` : "";
633
+ const from = d.source_member ? ` · from ${d.source_member}` : "";
634
+ const match = d.similarity == null ? "recent" : pct(d.similarity);
635
+ lines.push(` ${tag} ${where}${String(d.name ?? "").replace(/\.md$/, "")}${from} (${match})`);
636
+ if (d.snippet) lines.push(` ${d.snippet}`);
637
+ if (d.entity_id) lines.push(` (entity_id: ${d.entity_id})`);
638
+ }
639
+ return { content: [{ type: "text", text: lines.join("\n").trim() }] };
640
+ };
641
+
599
642
  server.tool(
600
- "search_notes",
601
- "Semantically search the saved notes & documents (meeting briefs, transcripts, meeting notes) " +
602
- "kept on contacts. Use this to pull relevant content from the record e.g. 'what did we discuss " +
603
- "about pricing', 'objections raised in past meetings', or to compare across a contact's meetings. " +
604
- "Pass `focus` to restrict to one person/company, or omit it to search across everyone. Returns the " +
605
- "matching documents (type, title, date, similarity, snippet); get the full body with get_account.",
643
+ "search",
644
+ "Search the company's knowledge in one call — DECLARED + DERIVED company knowledge (policies, playbooks, " +
645
+ "decisions, and what actually happened on accounts), the member's OWN private vault (their notes/drafts), " +
646
+ "and NOTES kept on contacts each ACL-filtered, fused best-first. This is the default retrieval tool: reach " +
647
+ "for it before answering from generic knowledge. Narrow with `scope`: 'company', 'personal', 'notes', or " +
648
+ "'all' (default everything this key may read). Returns matching items tagged by layer with a snippet. For " +
649
+ "a synthesized brief on ONE person/company use get_context; for who knows a topic use who_knows.",
606
650
  {
607
- question: z.string().describe("Natural-language query to match against document content."),
608
- focus: z.string().optional().describe("Optional restrict to one person/company (email, LinkedIn URL, domain, or entity UUID)."),
609
- limit: z.number().optional().describe("Max documents to return (default 8)."),
610
- },
611
- async ({ question, focus, limit }) => {
612
- const r = await post("/v2/notes/search", { question, focus, limit });
613
- if (!r.documents?.length) {
614
- return { content: [{ type: "text", text: `No saved documents matched "${question}".` }] };
615
- }
616
- const lines = [`Documents matching "${question}":`, ""];
617
- for (const d of r.documents) {
618
- const when = d.date ? ` [${relAge(d.date)}]` : "";
619
- // similarity is null for recency-matched hits (a note too fresh to be
620
- // embedded yet) — label those "recent" instead of a bogus 0%.
621
- const match = d.similarity == null ? "recent" : pct(d.similarity);
622
- lines.push(` ${d.type.replace(/_/g, " ")}${d.title ? ` · ${d.title}` : ""} (${match})${when}`);
623
- if (d.snippet) lines.push(` ${d.snippet}`);
624
- lines.push(` (entity_id: ${d.entity_id})`);
625
- }
626
- return { content: [{ type: "text", text: lines.join("\n").trim() }] };
651
+ question: z.string().describe("Natural-language query."),
652
+ scope: z.enum(["all", "company", "personal", "notes"]).optional().describe("Which layer(s) to search. Default 'all' fused across every layer this key can read."),
653
+ limit: z.number().optional().describe("Max results (default 8, max 20)."),
654
+ folder: z.string().optional().describe("Optional — for scope 'personal', restrict to one vault folder."),
627
655
  },
656
+ async (args) => runSearch(args),
628
657
  );
629
658
 
630
- // ===========================================================================
631
- // TOOL: search_company_knowledge — POST /v2/company/search
632
- // Semantic search over the SHARED company vault (declared knowledge — policies,
633
- // playbooks, decisions, the knowledge base) the workspace keeps in the Brain.
634
- // Permission-filtered server-side: a doc surfaces only if agents may read it, and
635
- // admins-only docs only to an admin key — so this never returns anything the
636
- // caller isn't allowed to see. Use it before answering from generic knowledge.
637
- // ===========================================================================
659
+ // ── Deprecated aliases → search. Kept so existing agents/configs keep working; removed after a window. ──
638
660
  server.tool(
639
661
  "search_company_knowledge",
640
- "Search the company's knowledge across BOTH layers of the brain: DECLARED knowledge (curated policies, " +
641
- "playbooks, decisions — the shared Company vault) and DERIVED knowledge (distilled from real activity — " +
642
- "what actually happened in emails, meetings, threads). Hybrid retrieval (semantic + full-text + recency, " +
643
- "fused) returns one best-first answer spanning both. Use this when a question is about how THIS company " +
644
- "works, its rules, its plays, a past decision, or what happened on an account — before falling back on " +
645
- "generic knowledge. Results are permission-filtered: you only ever see what the company allows agents to " +
646
- "read, and never anyone's private raw. Each hit is tagged [declared] or [derived].",
647
- {
648
- question: z.string().describe("Natural-language query to match against the company's declared and derived knowledge."),
649
- limit: z.number().optional().describe("Max documents to return (default 8)."),
650
- },
651
- async ({ question, limit }) => {
652
- const r = await post("/v2/company/search", { question, limit });
653
- if (!r.documents?.length) {
654
- return { content: [{ type: "text", text: `Nothing in the company knowledge matched "${question}".` }] };
655
- }
656
- const lines = [`Company knowledge matching "${question}":`, ""];
657
- for (const d of r.documents) {
658
- const tag = d.kind === "derived" ? "[derived]" : "[declared]";
659
- const where = d.ref ? `${d.ref} · ` : "";
660
- lines.push(` ${tag} ${where}${String(d.name ?? "").replace(/\.md$/, "")} (${pct(d.similarity)})`);
661
- if (d.snippet) lines.push(` ${d.snippet}`);
662
- }
663
- return { content: [{ type: "text", text: lines.join("\n").trim() }] };
664
- },
662
+ "DEPRECATED use `search` with scope 'company'. Searches the shared company knowledge (declared + derived).",
663
+ { question: z.string(), limit: z.number().optional() },
664
+ async ({ question, limit }) => runSearch({ question, scope: "company", limit }),
665
+ );
666
+ server.tool(
667
+ "search_my_vault",
668
+ "DEPRECATED use `search` with scope 'personal'. Searches the member's own private vault.",
669
+ { question: z.string(), folder: z.string().optional(), limit: z.number().optional() },
670
+ async ({ question, folder, limit }) => runSearch({ question, scope: "personal", limit, folder }),
671
+ );
672
+ server.tool(
673
+ "search_notes",
674
+ "DEPRECATED use `search` with scope 'notes'. Searches notes/documents kept on contacts.",
675
+ { question: z.string(), limit: z.number().optional() },
676
+ async ({ question, limit }) => runSearch({ question, scope: "notes", limit }),
665
677
  );
666
678
 
667
679
  // ===========================================================================
668
- // TOOL: search_my_vault — POST /v2/personal/search
669
- // Semantic search over the MEMBER'S OWN personal vault (their private PKM). The
670
- // counterpart to search_company_knowledge (shared) this is the member's private
671
- // notes, hard-scoped server-side to the member this key acts for. Never another
672
- // member's vault.
680
+ // TOOL: who_knows — POST /v2/who-knows
681
+ // Expertise routing: internal people whose own knowledge (meetings, notes,
682
+ // messages) matches a topic, ranked. Honest by construction someone only
683
+ // surfaces if their distilled knowledge actually matched.
673
684
  // ===========================================================================
674
685
  server.tool(
675
- "search_my_vault",
676
- "Semantically search the member's OWN personal vaulttheir private notes, thoughts, decisions, " +
677
- "project docs, briefs, and drafts (their PKM). Use this when a question is about what the user has " +
678
- "personally written, decided, or been thinking about 'my notes on X', 'what did I decide about Y', " +
679
- "'my draft for Z'. This is PRIVATE to this member and never exposes anyone else's vault. For the " +
680
- "company's shared knowledge use search_company_knowledge; for facts about accounts use get_context/query. " +
681
- "NOTE: if you're running where this member's vault is linked as local files, prefer reading those files " +
682
- "directly — fresher, fuller, and their private notes never leave the machine. On-device, use the CLI: " +
683
- "`nous vault search <query>`, `nous vault read <path>`, or `nous vault path`. Use THIS tool only when you " +
684
- "are NOT on those files (a remote agent, Slack, or the web).",
686
+ "who_knows",
687
+ "Find the internal people who know about a topic ranked by how much of their OWN knowledge " +
688
+ "(meetings, notes, messages distilled into the brain) matches it. Use for 'who knows about X', " +
689
+ "'who's the expert on Y', 'who should I ask about Z'. Returns people with a relevance score and a " +
690
+ "sample of the matching knowledge. Honest by construction: someone only appears if their knowledge " +
691
+ "genuinely matched an empty result means no one's tracked knowledge covers it yet.",
685
692
  {
686
- question: z.string().describe("Natural-language query to match against the member's own vault notes."),
687
- folder: z.enum(["inbox", "projects", "decisions", "accounts", "people", "resources", "archive", "thoughts", "briefs", "content", "company"]).optional().describe("Optional restrict to one vault folder."),
688
- limit: z.number().optional().describe("Max files to return (default 6)."),
693
+ topic: z.string().describe("The topic/area/system to find experts on."),
694
+ limit: z.number().optional().describe("Max people to return (default 5, max 20)."),
689
695
  },
690
- async ({ question, folder, limit }) => {
691
- const r = await post("/v2/personal/search", { question, folder, limit });
692
- if (!r.documents?.length) {
693
- return { content: [{ type: "text", text: `Nothing in your personal vault matched "${question}".` }] };
696
+ async ({ topic, limit }) => {
697
+ const r = await post("/v2/who-knows", { topic, limit });
698
+ if (!r.people?.length) {
699
+ return { content: [{ type: "text", text: `No one's tracked knowledge matched "${topic}" yet.` }] };
694
700
  }
695
- const lines = [`Your vault notes matching "${question}":`, ""];
696
- for (const d of r.documents) {
697
- const match = d.similarity == null ? "recent" : pct(d.similarity);
698
- lines.push(` ${d.path ?? d.name} (${match})`);
699
- if (d.snippet) lines.push(` ${d.snippet}`);
701
+ const lines = [`People who know about "${topic}":`, ""];
702
+ for (const p of r.people) {
703
+ lines.push(` ${p.name} (${p.mentions} matching${p.relevance != null ? `, relevance ${p.relevance}` : ""})`);
704
+ if (p.sample) lines.push(` ${p.sample}`);
700
705
  }
701
706
  return { content: [{ type: "text", text: lines.join("\n").trim() }] };
702
707
  },
@@ -789,6 +794,40 @@ export function createServer() {
789
794
 
790
795
 
791
796
 
797
+ // ===========================================================================
798
+ // TOOL: save_to_vault — POST /v2/personal/files
799
+ // Save a file into the MEMBER'S OWN private vault folder. Extracts a PDF/DOCX to text.
800
+ // ===========================================================================
801
+ server.tool(
802
+ "save_to_vault",
803
+ "Save a file into the MEMBER'S OWN private knowledge vault, in a folder they name " +
804
+ "(inbox, projects, decisions, companies, people, resources, archive, thoughts). Use it when the " +
805
+ "member asks you to keep or file something for them — 'save this to projects', 'put this PDF in " +
806
+ "resources'. Pass markdown/text as `content`, OR a PDF/DOCX as base64 in `file` (it's extracted to " +
807
+ "text automatically and stored so they can search it later). PRIVATE to the member — nothing is " +
808
+ "shared until they promote it to their department. NOT the shared company vault (use " +
809
+ "propose_company_file), NOT a note on a contact (use save_note).",
810
+ {
811
+ folder: z.string().describe("The vault folder: inbox | projects | decisions | companies | people | resources | archive | thoughts."),
812
+ name: z.string().describe("The file name, e.g. 'Q3 Plan.md' or 'vendor-contract.pdf'."),
813
+ content: z.string().optional().describe("The text/markdown content, when you already have it as text."),
814
+ 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."),
815
+ mime: z.string().optional().describe("The file's MIME type, e.g. 'application/pdf' or the docx type — helps pick the right extractor."),
816
+ subfolder: z.string().optional().describe("Optional subfolder within the folder."),
817
+ },
818
+ async ({ folder, name, content, file, mime, subfolder }) => {
819
+ try {
820
+ const r = await post("/v2/personal/files", { folder, name, content, file_base64: file, mime, subfolder });
821
+ return { content: [{ type: "text", text: `Saved "${name}" to your ${r?.folder || folder} vault — it's searchable now.` }] };
822
+ } catch (e) {
823
+ const msg = String(e?.message || e);
824
+ 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.` }] };
825
+ if (msg.includes("content_or_file_required")) return { content: [{ type: "text", text: `Nothing to save — pass either text content or a base64 file.` }] };
826
+ throw e;
827
+ }
828
+ }
829
+ );
830
+
792
831
  server.tool(
793
832
  "list_integrations",
794
833
  "List the integrations connected to this workspace (Gmail, HubSpot, Apollo, Instantly, LinkedIn, …) " +