@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/server.js +36 -207
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edda-business/mcp",
3
- "version": "0.52.0",
3
+ "version": "0.54.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,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 (the authoritative live catalog is `node scripts/list-tools.mjs`):
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 record · save_note · propose_vault_file · propose_company_file · merge_contacts
17
- * CORRECT retract_observation · unmerge_contacts
18
- * RUN get_workspace_status · whoami · list_integrations · set_workspace_profile · connect_integration
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.51.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 Context Graph for AI Agents. Edda is operated by the agent, not by a human " +
75
- "clicking around: call get_workspace_status at the start of a session to see what's set up " +
76
- "and what to set up next. Call get_context before drafting outreach or preparing for a " +
77
- "meeting. Call record after every interaction, or whenever you learn something.",
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(["draft_email", "follow_up", "meeting_prep", "call_prep", "account_review"])
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", "draft_email"])
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 record across meetings. Distinct from `record` (which logs
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 record across meetings — later you can " +
579
- "read the last few and see what changed. This is NOT for logging that an interaction happened " +
580
- "(use `record` with an interaction.* event for that), and NOT for the user's own GTM profile " +
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
- "Add or update a markdown file in the SHARED COMPANY vault — the workspace's declared " +
749
- "knowledge (policies, playbooks, decisions, SOPs, how-we-work) that every member's agent can " +
750
- "read via search_company_knowledge. Use this to contribute company-wide knowledge everyone " +
751
- "should have. ADMIN-ONLY: only an owner/admin may write the company vault; a regular member's " +
752
- "agent can read it but will get a permission message here. This is NOT the member's private " +
753
- "notes (use propose_vault_file) and NOT a note on a contact (use save_note). Pick the " +
754
- "destination company folder (it must be an existing folder in the company vault).",
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
- return { content: [{ type: "text", text: `Filed "${name}" into the company vault (${folder}). It's now searchable by the team's agents via search_company_knowledge.` }] };
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 filed — writing to the company vault is admin-only. You can READ it with search_company_knowledge, but only an owner/admin can add to it.` }] };
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;