@edda-business/mcp 0.53.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.
- package/package.json +1 -1
- package/src/server.js +189 -140
package/package.json
CHANGED
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* WRITE
|
|
17
|
-
*
|
|
18
|
-
* RUN
|
|
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.
|
|
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
|
-
|
|
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
|
-
"
|
|
310
|
-
"
|
|
311
|
-
"
|
|
312
|
-
"
|
|
313
|
-
"
|
|
314
|
-
"
|
|
315
|
-
"
|
|
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
|
-
|
|
318
|
-
|
|
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,
|
|
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
|
-
"
|
|
349
|
-
"
|
|
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
|
+
"DEPRECATED — use `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
|
-
"
|
|
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.
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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/
|
|
589
|
-
return { content: [{ type: "text", text: `
|
|
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:
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
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
|
-
"
|
|
601
|
-
"
|
|
602
|
-
"
|
|
603
|
-
"
|
|
604
|
-
"
|
|
605
|
-
"
|
|
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
|
|
608
|
-
|
|
609
|
-
limit: z.number().optional().describe("Max
|
|
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
|
-
"
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
"
|
|
646
|
-
"
|
|
647
|
-
{
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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:
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
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
|
-
"
|
|
676
|
-
"
|
|
677
|
-
"
|
|
678
|
-
"
|
|
679
|
-
"
|
|
680
|
-
"
|
|
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
|
-
|
|
687
|
-
|
|
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 ({
|
|
691
|
-
const r = await post("/v2/
|
|
692
|
-
if (!r.
|
|
693
|
-
return { content: [{ type: "text", text: `
|
|
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 = [`
|
|
696
|
-
for (const
|
|
697
|
-
|
|
698
|
-
lines.push(`
|
|
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
|
},
|
|
@@ -710,27 +715,37 @@ export function createServer() {
|
|
|
710
715
|
// ===========================================================================
|
|
711
716
|
server.tool(
|
|
712
717
|
"propose_company_file",
|
|
713
|
-
"
|
|
714
|
-
"
|
|
715
|
-
"
|
|
716
|
-
"
|
|
717
|
-
"
|
|
718
|
-
"
|
|
719
|
-
"
|
|
718
|
+
"Propose a markdown file for the SHARED COMPANY vault — the workspace's declared knowledge " +
|
|
719
|
+
"(policies, playbooks, decisions, SOPs, how-we-work) that every member's agent can read via " +
|
|
720
|
+
"search_company_knowledge. This OPENS A PULL REQUEST against the connected company repo for a " +
|
|
721
|
+
"human to review and merge in GitHub — nothing goes live until it's merged (company knowledge " +
|
|
722
|
+
"is shared + institutional, so review-before-publish is the default; on merge the normal git " +
|
|
723
|
+
"sync ingests it). Use it to contribute company-wide knowledge. ADMIN-ONLY: only an owner/admin " +
|
|
724
|
+
"may propose; a regular member's agent can READ the vault but gets a permission message here. " +
|
|
725
|
+
"NOT the member's private notes (use propose_vault_file), NOT a note on a contact (use save_note).",
|
|
720
726
|
{
|
|
721
727
|
folder: z.string().describe("The company-vault folder to file under (e.g. 'company-wiki', 'decisions', 'projects', 'clients'). Must be an existing company folder; no slashes."),
|
|
722
728
|
name: z.string().describe("The file name, ending in .md, e.g. 'Refund Policy.md' or 'Q3 Planning — 2026-08-22.md'."),
|
|
723
729
|
content: z.string().describe("The full markdown content of the file."),
|
|
724
730
|
subfolder: z.string().optional().describe("Optional subfolder within the folder."),
|
|
731
|
+
message: z.string().optional().describe("Optional PR title / commit message describing the change."),
|
|
732
|
+
pr_group: z.string().optional().describe("Optional key to batch several proposals from one work session into a SINGLE pull request (same key → same PR)."),
|
|
725
733
|
},
|
|
726
|
-
async ({ folder, name, content, subfolder }) => {
|
|
734
|
+
async ({ folder, name, content, subfolder, message, pr_group }) => {
|
|
727
735
|
try {
|
|
728
|
-
await post("/v2/company/propose", { folder, name, content, subfolder });
|
|
729
|
-
|
|
736
|
+
const r = await post("/v2/company/propose", { folder, name, content, subfolder, message, pr_group });
|
|
737
|
+
const how = r?.added_to_existing ? "added to the open PR" : "opened a pull request";
|
|
738
|
+
return { content: [{ type: "text", text: `Proposed "${name}" — ${how}${r?.pr_url ? `: ${r.pr_url}` : ""}. Review the diff and merge it in GitHub to publish into company knowledge.` }] };
|
|
730
739
|
} catch (e) {
|
|
731
740
|
const msg = String(e?.message || e);
|
|
732
741
|
if (msg.includes("admins_only") || msg.includes("(403)")) {
|
|
733
|
-
return { content: [{ type: "text", text: `Not
|
|
742
|
+
return { content: [{ type: "text", text: `Not proposed — writing to the company vault is admin-only. You can READ it with search_company_knowledge, but only an owner/admin can propose changes.` }] };
|
|
743
|
+
}
|
|
744
|
+
if (msg.includes("no_company_repo")) {
|
|
745
|
+
return { content: [{ type: "text", text: `Not proposed — no company GitHub repo is connected. An admin connects one under Company vault first.` }] };
|
|
746
|
+
}
|
|
747
|
+
if (msg.includes("repo_write_denied")) {
|
|
748
|
+
return { content: [{ type: "text", text: `Not proposed — the connected repo token is read-only. An admin must reconnect it with Contents:write + Pull requests:write.` }] };
|
|
734
749
|
}
|
|
735
750
|
throw e;
|
|
736
751
|
}
|
|
@@ -779,6 +794,40 @@ export function createServer() {
|
|
|
779
794
|
|
|
780
795
|
|
|
781
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
|
+
|
|
782
831
|
server.tool(
|
|
783
832
|
"list_integrations",
|
|
784
833
|
"List the integrations connected to this workspace (Gmail, HubSpot, Apollo, Instantly, LinkedIn, …) " +
|