@edda-business/mcp 0.52.0 → 0.54.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 +36 -207
package/package.json
CHANGED
package/src/server.js
CHANGED
|
@@ -10,19 +10,19 @@
|
|
|
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
|
|
13
|
+
* Tools, by group:
|
|
14
14
|
* READ get_context · get_account · query · attention · verify ·
|
|
15
15
|
* search_notes · search_company_knowledge · search_my_vault
|
|
16
|
-
* WRITE
|
|
17
|
-
* CORRECT
|
|
18
|
-
* RUN
|
|
16
|
+
* WRITE save_note · propose_vault_file · propose_company_file · merge_contacts
|
|
17
|
+
* CORRECT unmerge_contacts
|
|
18
|
+
* RUN list_integrations · connect_integration
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
22
22
|
import { z } from "zod";
|
|
23
23
|
import { get, post, del } from "./client.js";
|
|
24
24
|
|
|
25
|
-
export const SERVER_VERSION = "0.
|
|
25
|
+
export const SERVER_VERSION = "0.54.0";
|
|
26
26
|
|
|
27
27
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
28
28
|
|
|
@@ -71,10 +71,10 @@ export function createServer() {
|
|
|
71
71
|
name: "edda",
|
|
72
72
|
version: SERVER_VERSION,
|
|
73
73
|
description:
|
|
74
|
-
"Edda — the
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"
|
|
74
|
+
"Edda — the company knowledge layer for AI agents. The agent reads engineered, " +
|
|
75
|
+
"epistemics-tagged context instead of raw rows. Call get_context before preparing for a " +
|
|
76
|
+
"meeting or a decision about a person; search_company_knowledge for how the company works; " +
|
|
77
|
+
"save_note to keep a brief or transcript on a contact.",
|
|
78
78
|
icons: [
|
|
79
79
|
{ src: "https://opennous.cloud/newlogoP.png", mimeType: "image/png", sizes: ["64x64"] },
|
|
80
80
|
],
|
|
@@ -96,7 +96,7 @@ export function createServer() {
|
|
|
96
96
|
"act on it, 'suspect'/'expired' verify first.",
|
|
97
97
|
{
|
|
98
98
|
focus: z.string().describe("Who to look up — an email, a LinkedIn URL, a domain, an entity UUID, or a name. A name may match several people; you'll get candidates to choose from."),
|
|
99
|
-
intent: z.enum(["
|
|
99
|
+
intent: z.enum(["follow_up", "meeting_prep", "call_prep", "account_review"])
|
|
100
100
|
.optional()
|
|
101
101
|
.describe("What you are about to do — shapes which context surfaces (default: account_review)"),
|
|
102
102
|
budget_tokens: z.number().optional().describe("Approximate token budget for the context block"),
|
|
@@ -190,7 +190,7 @@ export function createServer() {
|
|
|
190
190
|
{
|
|
191
191
|
id: z.string().describe("Who to look up — an email, an entity UUID, or a name. A name may match several people; you'll get candidates to choose from."),
|
|
192
192
|
intent: z
|
|
193
|
-
.enum(["meeting_prep", "call_prep", "account_review", "follow_up"
|
|
193
|
+
.enum(["meeting_prep", "call_prep", "account_review", "follow_up"])
|
|
194
194
|
.optional()
|
|
195
195
|
.describe(
|
|
196
196
|
"What you're about to do. Shapes how much of their history comes back: a meeting brief wants " +
|
|
@@ -385,38 +385,6 @@ export function createServer() {
|
|
|
385
385
|
}
|
|
386
386
|
);
|
|
387
387
|
|
|
388
|
-
// ===========================================================================
|
|
389
|
-
// TOOL: record — POST /v2/observations
|
|
390
|
-
// The single write verb. You observe — Edda derives the updated facts.
|
|
391
|
-
// ===========================================================================
|
|
392
|
-
server.tool(
|
|
393
|
-
"record",
|
|
394
|
-
"Record what happened or what you learned about a person or company. You never overwrite " +
|
|
395
|
-
"anything — you observe, and Edda derives the updated facts. Use kind:'event' for an interaction " +
|
|
396
|
-
"(property like 'interaction.email_sent', 'interaction.call_held', 'interaction.email_reply') and " +
|
|
397
|
-
"kind:'state' for a fact (property like 'job_title', 'deal.proposal_amount'). Examples — sent an " +
|
|
398
|
-
"email: {kind:'event',property:'interaction.email_sent',value:{description:'intro email'}}; " +
|
|
399
|
-
"learned their title changed: {kind:'state',property:'job_title',value:'VP of Engineering'}; " +
|
|
400
|
-
"a fact ended (they left): {kind:'state',property:'job_title',value:null}.",
|
|
401
|
-
{
|
|
402
|
-
focus: z.string().describe("Email address or entity UUID of the person or company"),
|
|
403
|
-
observations: z.array(z.object({
|
|
404
|
-
kind: z.enum(["event", "state"]).describe("event = an interaction; state = a fact"),
|
|
405
|
-
property: z.string().describe("e.g. 'interaction.email_sent' or 'job_title'"),
|
|
406
|
-
value: z.any().optional().describe("the event detail or the fact value; null = the fact ended"),
|
|
407
|
-
source: z.string().optional().describe("where this came from (default: agent)"),
|
|
408
|
-
})).describe("One or more observations to record"),
|
|
409
|
-
},
|
|
410
|
-
async ({ focus, observations }) => {
|
|
411
|
-
const result = await post("/v2/observations", { focus, observations });
|
|
412
|
-
const parts = [`Recorded ${result.recorded} observation${result.recorded !== 1 ? "s" : ""}.`];
|
|
413
|
-
if (result.claims_recomputed?.length) {
|
|
414
|
-
parts.push(`Facts updated: ${result.claims_recomputed.join(", ")}.`);
|
|
415
|
-
}
|
|
416
|
-
parts.push(`(entity_id: ${result.entity_id})`);
|
|
417
|
-
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
418
|
-
}
|
|
419
|
-
);
|
|
420
388
|
|
|
421
389
|
|
|
422
390
|
// ===========================================================================
|
|
@@ -566,8 +534,7 @@ export function createServer() {
|
|
|
566
534
|
// TOOL: save_note — POST /v2/notes
|
|
567
535
|
// Attach a long-form artifact to a CONTACT: a meeting brief you wrote, a
|
|
568
536
|
// transcript, pre-meeting prep, or a plain note. Append-only and dated, so the
|
|
569
|
-
// contact builds a
|
|
570
|
-
// that an interaction happened) — this keeps the document itself.
|
|
537
|
+
// contact builds up a document trail across meetings.
|
|
571
538
|
// ===========================================================================
|
|
572
539
|
server.tool(
|
|
573
540
|
"save_note",
|
|
@@ -575,11 +542,9 @@ export function createServer() {
|
|
|
575
542
|
"brief you wrote, a transcript, pre-meeting prep, research, or a plain note. Use this whenever " +
|
|
576
543
|
"you produce something durable about a specific contact that's worth keeping for next time (e.g. " +
|
|
577
544
|
"after writing a meeting brief, save it to the contact so future meetings can reference it). " +
|
|
578
|
-
"Notes are append-only and dated, so a contact builds a
|
|
579
|
-
"read the last few and see what changed.
|
|
580
|
-
"
|
|
581
|
-
"(that lives in their context files — sync it with `sync_icp`). Put the full text in `content` — it's kept for agents to read; the " +
|
|
582
|
-
"UI shows the title and date, not the whole body.",
|
|
545
|
+
"Notes are append-only and dated, so a contact builds a document trail across meetings — later " +
|
|
546
|
+
"you can read the last few and see what changed. Put the full text in `content` — it's kept for " +
|
|
547
|
+
"agents to read; the UI shows the title and date, not the whole body.",
|
|
583
548
|
{
|
|
584
549
|
focus: z.string().describe("Who to attach it to — an email, LinkedIn URL, domain, or entity UUID (not a bare name)."),
|
|
585
550
|
content: z.string().describe("The full note or document text (a short note or a complete brief/transcript)."),
|
|
@@ -745,131 +710,43 @@ export function createServer() {
|
|
|
745
710
|
// ===========================================================================
|
|
746
711
|
server.tool(
|
|
747
712
|
"propose_company_file",
|
|
748
|
-
"
|
|
749
|
-
"
|
|
750
|
-
"
|
|
751
|
-
"
|
|
752
|
-
"
|
|
753
|
-
"
|
|
754
|
-
"
|
|
713
|
+
"Propose a markdown file for the SHARED COMPANY vault — the workspace's declared knowledge " +
|
|
714
|
+
"(policies, playbooks, decisions, SOPs, how-we-work) that every member's agent can read via " +
|
|
715
|
+
"search_company_knowledge. This OPENS A PULL REQUEST against the connected company repo for a " +
|
|
716
|
+
"human to review and merge in GitHub — nothing goes live until it's merged (company knowledge " +
|
|
717
|
+
"is shared + institutional, so review-before-publish is the default; on merge the normal git " +
|
|
718
|
+
"sync ingests it). Use it to contribute company-wide knowledge. ADMIN-ONLY: only an owner/admin " +
|
|
719
|
+
"may propose; a regular member's agent can READ the vault but gets a permission message here. " +
|
|
720
|
+
"NOT the member's private notes (use propose_vault_file), NOT a note on a contact (use save_note).",
|
|
755
721
|
{
|
|
756
722
|
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."),
|
|
757
723
|
name: z.string().describe("The file name, ending in .md, e.g. 'Refund Policy.md' or 'Q3 Planning — 2026-08-22.md'."),
|
|
758
724
|
content: z.string().describe("The full markdown content of the file."),
|
|
759
725
|
subfolder: z.string().optional().describe("Optional subfolder within the folder."),
|
|
726
|
+
message: z.string().optional().describe("Optional PR title / commit message describing the change."),
|
|
727
|
+
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)."),
|
|
760
728
|
},
|
|
761
|
-
async ({ folder, name, content, subfolder }) => {
|
|
729
|
+
async ({ folder, name, content, subfolder, message, pr_group }) => {
|
|
762
730
|
try {
|
|
763
|
-
await post("/v2/company/propose", { folder, name, content, subfolder });
|
|
764
|
-
|
|
731
|
+
const r = await post("/v2/company/propose", { folder, name, content, subfolder, message, pr_group });
|
|
732
|
+
const how = r?.added_to_existing ? "added to the open PR" : "opened a pull request";
|
|
733
|
+
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.` }] };
|
|
765
734
|
} catch (e) {
|
|
766
735
|
const msg = String(e?.message || e);
|
|
767
736
|
if (msg.includes("admins_only") || msg.includes("(403)")) {
|
|
768
|
-
return { content: [{ type: "text", text: `Not
|
|
737
|
+
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.` }] };
|
|
738
|
+
}
|
|
739
|
+
if (msg.includes("no_company_repo")) {
|
|
740
|
+
return { content: [{ type: "text", text: `Not proposed — no company GitHub repo is connected. An admin connects one under Company vault first.` }] };
|
|
741
|
+
}
|
|
742
|
+
if (msg.includes("repo_write_denied")) {
|
|
743
|
+
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.` }] };
|
|
769
744
|
}
|
|
770
745
|
throw e;
|
|
771
746
|
}
|
|
772
747
|
},
|
|
773
748
|
);
|
|
774
749
|
|
|
775
|
-
// ===========================================================================
|
|
776
|
-
// TOOL: get_workspace_status — GET /v2/workspace/status
|
|
777
|
-
// The "one main call." Edda is operated by the agent, so the agent needs to
|
|
778
|
-
// know the state of the workspace: is it onboarded, is the GTM foundation built,
|
|
779
|
-
// which integrations are connected, is CRM sync configured, are events live —
|
|
780
|
-
// and what to set up next. Call this at the start of a session.
|
|
781
|
-
// ===========================================================================
|
|
782
|
-
server.tool(
|
|
783
|
-
"get_workspace_status",
|
|
784
|
-
"See the whole setup state of this workspace in one call, plus a ranked NEXT STEPS list (each step " +
|
|
785
|
-
"carries its own why/how). Edda is operated by you, the agent — call this at the START of a session " +
|
|
786
|
-
"and walk the user top-down through the steps it returns; the server sequences them by current " +
|
|
787
|
-
"state, so trust that order. Two constraints when acting on them: (1) Gmail (Google OAuth) and " +
|
|
788
|
-
"LinkedIn (no public API — Edda uses Unipile) CANNOT be connected by you — point the user to the " +
|
|
789
|
-
"Integrations page; key-based tools (Prospeo, Apollo, Instantly, HubSpot token) you CAN connect via " +
|
|
790
|
-
"connect_integration, and CSV import is a user action in the app. (2) Respect the plan — never push " +
|
|
791
|
-
"a feature it doesn't include (e.g. CRM sync on free). Recommend the next 1-2 steps, don't dump the " +
|
|
792
|
-
"whole list.",
|
|
793
|
-
{},
|
|
794
|
-
async () => {
|
|
795
|
-
const s = await get("/v2/workspace/status");
|
|
796
|
-
const setup = s.setup ?? {};
|
|
797
|
-
const lines = [];
|
|
798
|
-
|
|
799
|
-
const ws = s.workspace ?? {};
|
|
800
|
-
lines.push(`WORKSPACE: ${ws.name || "(unnamed)"}${ws.website ? ` · ${ws.website}` : ""}${ws.business_type ? ` · ${ws.business_type}` : ""}`);
|
|
801
|
-
const pl = s.plan ?? {};
|
|
802
|
-
lines.push(`PLAN: ${pl.name || pl.id || "free"}${pl.crm_sync === false ? " (CRM sync not included — do not offer it)" : ""}`);
|
|
803
|
-
if (s.self_hosted) {
|
|
804
|
-
const e = s.env_integrations ?? {};
|
|
805
|
-
const mk = (b) => (b ? "✓ set" : "✗ NOT set");
|
|
806
|
-
lines.push("SELF-HOSTED — these channels are wired via edda.env (you can't set env vars; tell the operator to set + restart):");
|
|
807
|
-
lines.push(` LinkedIn/Unipile: ${mk(e.linkedin_unipile)} Email/Resend: ${mk(e.email_resend)} Gmail OAuth: ${mk(e.gmail_oauth)}`);
|
|
808
|
-
}
|
|
809
|
-
lines.push("");
|
|
810
|
-
|
|
811
|
-
const mark = (b) => (b ? "✓" : "✗");
|
|
812
|
-
lines.push("SETUP:");
|
|
813
|
-
lines.push(` ${mark(setup.onboarding?.done)} Profile${setup.onboarding?.done ? "" : ` — missing ${(setup.onboarding?.missing ?? []).join(", ") || "details"}`}`);
|
|
814
|
-
const ints = setup.integrations?.connected ?? [];
|
|
815
|
-
lines.push(` ${mark((setup.integrations?.count ?? 0) > 0)} Integrations (${setup.integrations?.count ?? 0})${ints.length ? `: ${ints.map((i) => i.name).join(", ")}` : ""}`);
|
|
816
|
-
const crm = setup.crm_sync ?? {};
|
|
817
|
-
if (crm.available === false) {
|
|
818
|
-
lines.push(` – CRM sync (not on the ${pl.name || pl.id || "current"} plan)`);
|
|
819
|
-
} else {
|
|
820
|
-
lines.push(` ${mark(crm.configured)} CRM sync${crm.configured ? `: ${(crm.providers ?? []).map((p) => p.provider).join(", ")}` : ""}${crm.pending_hygiene_proposals ? ` · ${crm.pending_hygiene_proposals} hygiene proposal(s) to review` : ""}`);
|
|
821
|
-
}
|
|
822
|
-
lines.push(` ${mark(setup.enrichment?.connected)} Enrichment${setup.enrichment?.provider ? `: ${setup.enrichment.provider}` : ""}`);
|
|
823
|
-
lines.push(` ${mark((setup.webhooks?.count ?? 0) > 0 || (setup.triggers?.count ?? 0) > 0)} Events — ${setup.webhooks?.count ?? 0} webhook(s), ${setup.triggers?.count ?? 0} trigger(s)`);
|
|
824
|
-
const rec = setup.recommended ?? {};
|
|
825
|
-
lines.push("");
|
|
826
|
-
lines.push("RECOMMENDED CHANNELS (connect these first):");
|
|
827
|
-
lines.push(` ${mark(rec.email)} Email / Gmail ${mark(rec.linkedin)} LinkedIn ${mark(rec.meeting_notetaker)} Meeting note-taker`);
|
|
828
|
-
lines.push(` Records imported: ${setup.records?.count ?? 0}`);
|
|
829
|
-
|
|
830
|
-
if (s.next_steps?.length) {
|
|
831
|
-
lines.push("");
|
|
832
|
-
lines.push("NEXT STEPS:");
|
|
833
|
-
for (const step of s.next_steps) {
|
|
834
|
-
lines.push(` • ${step.title}`);
|
|
835
|
-
if (step.why) lines.push(` why: ${step.why}`);
|
|
836
|
-
if (step.how) lines.push(` how: ${step.how}`);
|
|
837
|
-
}
|
|
838
|
-
} else {
|
|
839
|
-
lines.push("");
|
|
840
|
-
lines.push("Everything's set up. Nothing pending.");
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
844
|
-
}
|
|
845
|
-
);
|
|
846
|
-
|
|
847
|
-
// ===========================================================================
|
|
848
|
-
// TOOL: set_workspace_profile — POST /v2/workspace/onboarding
|
|
849
|
-
// Agent-driven onboarding. Instead of a human clicking through a wizard in the
|
|
850
|
-
// app, you collect the basics from the user in conversation and write them
|
|
851
|
-
// here. This is the first thing get_workspace_status asks for when a workspace
|
|
852
|
-
// is new.
|
|
853
|
-
// ===========================================================================
|
|
854
|
-
server.tool(
|
|
855
|
-
"set_workspace_profile",
|
|
856
|
-
"Set or update the workspace's basic identity — its company name and website. Send only the fields " +
|
|
857
|
-
"you're setting or changing.",
|
|
858
|
-
{
|
|
859
|
-
name: z.string().optional().describe("The company / workspace name."),
|
|
860
|
-
website: z.string().optional().describe("The company website."),
|
|
861
|
-
},
|
|
862
|
-
async ({ name, website }) => {
|
|
863
|
-
const r = await post("/v2/workspace/onboarding", { name, website });
|
|
864
|
-
const w = r.workspace ?? {};
|
|
865
|
-
const set = [
|
|
866
|
-
w.name && `name=${w.name}`,
|
|
867
|
-
w.website && `site=${w.website}`,
|
|
868
|
-
].filter(Boolean);
|
|
869
|
-
return { content: [{ type: "text", text:
|
|
870
|
-
`Workspace profile saved.${set.length ? ` ${set.join(" · ")}.` : ""}` }] };
|
|
871
|
-
}
|
|
872
|
-
);
|
|
873
750
|
|
|
874
751
|
// ===========================================================================
|
|
875
752
|
// TOOL: connect_integration — POST /v2/workspace/integrations
|
|
@@ -910,36 +787,6 @@ export function createServer() {
|
|
|
910
787
|
}
|
|
911
788
|
);
|
|
912
789
|
|
|
913
|
-
// ===========================================================================
|
|
914
|
-
// TOOLS: the CORRECTION layer — unsay something recorded by mistake. `record` and
|
|
915
|
-
// `save_note` are how you write; these are how you take it back. Both heal the
|
|
916
|
-
// derived layer: retracting an observation re-derives the claim from what remains,
|
|
917
|
-
// deleting a note drops it from search/context. DELETE /v2/observations|notes/:id.
|
|
918
|
-
// ===========================================================================
|
|
919
|
-
server.tool(
|
|
920
|
-
"retract_observation",
|
|
921
|
-
"RETRACT an observation you recorded by mistake, and heal the record. Pass the observation's " +
|
|
922
|
-
"`id` (returned by `record`). Edda deletes it and re-derives the affected fact from the " +
|
|
923
|
-
"observations that remain — so a wrong value you observed is un-observed and the claim reverts as " +
|
|
924
|
-
"if it had never happened; if it was the only observation for that fact, the fact is invalidated. " +
|
|
925
|
-
"Use this when you recorded the wrong thing (wrong value, wrong person, a test), NOT to represent a " +
|
|
926
|
-
"real change over time — a genuine change is a NEW `record`, which supersedes by recency.",
|
|
927
|
-
{
|
|
928
|
-
id: z.string().describe("The observation id to retract (from a prior `record` result)."),
|
|
929
|
-
},
|
|
930
|
-
async ({ id }) => {
|
|
931
|
-
try {
|
|
932
|
-
const r = await del(`/v2/observations/${encodeURIComponent(id)}`);
|
|
933
|
-
return { content: [{ type: "text", text:
|
|
934
|
-
`Observation retracted. The claim for ${r.property} was ${r.claim === "invalidated" ? "invalidated (no observations left)" : "re-derived from the remaining observations"}.` }] };
|
|
935
|
-
} catch (e) {
|
|
936
|
-
const msg = /observation_not_found/.test(e.message)
|
|
937
|
-
? "No observation with that id in this workspace — check the id from the record result."
|
|
938
|
-
: `Couldn't retract the observation: ${e.message}`;
|
|
939
|
-
return { content: [{ type: "text", text: msg }] };
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
);
|
|
943
790
|
|
|
944
791
|
|
|
945
792
|
server.tool(
|
|
@@ -956,24 +803,6 @@ export function createServer() {
|
|
|
956
803
|
}
|
|
957
804
|
);
|
|
958
805
|
|
|
959
|
-
server.tool(
|
|
960
|
-
"whoami",
|
|
961
|
-
"Report who this API key acts AS and who else is on the workspace — the agent's own identity in " +
|
|
962
|
-
"Edda. Returns your scope (a MEMBER key sees only that member's private content plus the shared " +
|
|
963
|
-
"graph; an ADMIN key sees all raw content) and the team roster with names and roles. Use it to " +
|
|
964
|
-
"understand whose view you have and to reference teammates. Emails show only for an admin key.",
|
|
965
|
-
{},
|
|
966
|
-
async () => {
|
|
967
|
-
const r = await get("/v2/workspace/members");
|
|
968
|
-
const scope = r.you?.scope === "admin"
|
|
969
|
-
? "an ADMIN key — you see all raw content on this workspace"
|
|
970
|
-
: "a MEMBER key — you see only your own private content plus the shared graph";
|
|
971
|
-
const roster = (r.members || [])
|
|
972
|
-
.map(m => ` • ${m.name || "(unnamed)"} — ${m.role}${m.you ? " (you)" : ""}${m.email ? ` · ${m.email}` : ""}`)
|
|
973
|
-
.join("\n");
|
|
974
|
-
return { content: [{ type: "text", text: `You are ${scope}.\n\nWorkspace members (${r.count ?? 0}):\n${roster || " (none)"}` }] };
|
|
975
|
-
}
|
|
976
|
-
);
|
|
977
806
|
|
|
978
807
|
|
|
979
808
|
return server;
|