@opennous/mcp 0.46.0 → 0.48.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennous/mcp",
3
- "version": "0.46.0",
3
+ "version": "0.48.0",
4
4
  "description": "Nous — the Context Graph for AI Agents.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/http.js CHANGED
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * Nous MCP Server — hosted, multi-tenant HTTP entrypoint (mcp.opennous.cloud).
5
5
  *
6
- * Serves the same seven tools (server.js) over the MCP Streamable HTTP transport,
6
+ * Serves the same tool set (server.js) over the MCP Streamable HTTP transport,
7
7
  * so cloud clients that cannot launch a local process — n8n cloud above all —
8
8
  * can connect by pasting one URL plus their workspace API key.
9
9
  *
package/src/server.js CHANGED
@@ -10,34 +10,24 @@
10
10
  * sees raw rows — it gets engineered, epistemics-tagged context. It never
11
11
  * "updates" — it records observations; Nous derives.
12
12
  *
13
- * Tools:
14
- * get_context engineered context for a task (draft_email, follow_up, ...) + ICP fit score
15
- * get_account — the full account record: every claim + the timeline + ICP fit score
16
- * merge_contacts — fold two duplicate records for the same person into one (lossless, reversible)
17
- * record record what happened / what you learned (observe, never update)
18
- * query — retrieve + summarise a corpus of activity across many people
19
- * attention — what needs your attention (accounts gone quiet, facts decayed)
20
- * verify — re-check a fact before acting on it
21
- * get_foundation — read the user's own rules: voice, outreach, icp, positioning
22
- * save_note — attach a note/document (meeting brief, transcript, prep) to a contact
23
- * search_notes — semantic search over saved notes & documents
24
- * get_workspace_status — what's set up in this workspace + a ranked next_steps list (call first)
25
- * set_workspace_profile— agent-driven onboarding: set the workspace's name, site, type, ICP
26
- * build_icp_model — build/rebuild the ICP scoring model from the recorded GTM context
27
- * train_icp_model — build the ICP model from real closed-won/lost deals (contrastive lift)
28
- * sync_icp — sync the user's EXISTING ICP/positioning files into Nous (file → graph)
29
- * export_icp_model — get the learned ICP model as a block to write back into their ICP file (graph → file)
30
- * connect_integration — connect a key-based integration (Apollo, Prospeo, HubSpot, …)
31
- * configure_crm_sync — set CRM sync rules (auto-sync, create policy, hygiene cadence)
32
- * sync_crm_now — run an immediate incremental/full CRM pull (don't wait for the daily cron)
33
- * get_routing_preferences — Claude Code routing prefs to default GTM to Nous (write to CLAUDE.md)
13
+ * Tools, by group (the authoritative live catalog is `node scripts/list-tools.mjs`):
14
+ * READ get_context · get_account · verify · query · attention · get_action_items ·
15
+ * pipeline · pipeline_intelligence · campaign_performance · score ·
16
+ * search_notes · get_foundation · get_insights
17
+ * WRITE record · record_signal · save_note · propose_vault_file · merge_contacts ·
18
+ * sync_foundation
19
+ * ACT send_linkedin_message
20
+ * CORRECT retract_observation · delete_note
21
+ * RUN get_workspace_status · set_workspace_profile · build_icp_model · train_icp_model ·
22
+ * sync_icp · export_icp_model · connect_integration · configure_crm_sync ·
23
+ * sync_crm_now · scrape_engagers · get_routing_preferences
34
24
  */
35
25
 
36
26
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
37
27
  import { z } from "zod";
38
- import { get, post } from "./client.js";
28
+ import { get, post, del } from "./client.js";
39
29
 
40
- export const SERVER_VERSION = "0.42.0";
30
+ export const SERVER_VERSION = "0.48.0";
41
31
 
42
32
  // ─── helpers ──────────────────────────────────────────────────────────────────
43
33
 
@@ -508,7 +498,7 @@ export function createServer() {
508
498
  "Retrieve and summarise activity across many people. Three powers:\n" +
509
499
  " 1. return:'entities' groups results by person/company (one row per entity, ranked by " +
510
500
  "most-recent matching activity). Use for 'hottest leads', 'who replied this week', " +
511
- "'who's in evaluating stage'.\n" +
501
+ "'who's in the negotiation stage'.\n" +
512
502
  " 2. `without` subtracts entities — 'sent in 5d MINUS replied in 5d' = 'no-reply leads'. " +
513
503
  "'activity in 30d MINUS activity in 5d' = 'cooled leads'.\n" +
514
504
  " 3. rollups.by_value appears when scope.kind='state' — counts entities by current value " +
@@ -679,19 +669,31 @@ export function createServer() {
679
669
  const camps = r.campaigns || [];
680
670
  if (!camps.length) return { content: [{ type: "text", text: "No campaign replies logged yet." }] };
681
671
  const t = r.totals || {};
682
- const pct = (n) => `${Math.round((n || 0) * 100)}%`;
672
+ const pct = (n) => n == null ? "—" : `${Math.round(n * 100)}%`;
683
673
  const lines = camps.map(c => {
674
+ const cap = c.captured || {};
675
+ const pt = c.provider_totals, pr = c.provider_rates;
684
676
  const tiers = c.tier_of_positive || {};
685
677
  const good = (tiers.tier_1 || 0) + (tiers.tier_2 || 0);
686
- const conv = `${c.conversions || 0} client${c.conversions === 1 ? "" : "s"}${c.median_days_to_client != null ? ` (avg ${c.median_days_to_client}d to close)` : ""}${c.in_progress ? `, ${c.in_progress} in evaluation` : ""}`;
687
- const steps = (c.steps || []).map(s => ` step ${s.step ?? ""}: ${s.positive}/${s.replies} positive (${pct(s.positive_rate)})`).join("\n");
678
+ const funnel = pt
679
+ ? ` funnel: ${pt.sent ?? "?"} sent ${pt.contacted ?? "?"} contacted → ${pt.replies ?? "?"} replies (${pct(pr?.reply_rate)}) → ${pt.opportunities ?? "?"} opportunities (${pct(pr?.opportunity_rate)})`
680
+ : ` funnel: (no provider analytics yet)`;
681
+ const conv = `${c.conversions || 0} client${c.conversions === 1 ? "" : "s"}${c.median_days_to_client != null ? ` (avg ${c.median_days_to_client}d)` : ""}${c.in_progress ? `, ${c.in_progress} in evaluation` : ""}`;
682
+ const steps = (c.steps || []).map(s => {
683
+ const subj = s.subject ? ` — "${s.subject}"` : "";
684
+ return ` step ${s.step ?? "—"}${subj}: ${s.positive}/${s.replies} positive (${pct(s.positive_rate)})`;
685
+ }).join("\n");
686
+ const obj = (c.top_objections || []).length
687
+ ? `\n objections/pains: ${c.top_objections.slice(0, 4).map(o => `“${String(o).slice(0, 90)}”`).join("; ")}`
688
+ : "";
688
689
  return ` ${c.campaign_name || c.campaign_id}\n` +
689
- ` ${c.replies} replies — ${c.positive} positive (${pct(c.positive_rate)}), ${c.neutral} neutral, ${c.negative} negative\n` +
690
+ funnel + "\n" +
691
+ ` captured sample: ${cap.replies} replies — ${cap.positive} positive (${pct(cap.positive_rate)}), ${cap.neutral} neutral, ${cap.negative} negative\n` +
690
692
  ` converted: ${conv}\n` +
691
- ` positive repliers: avg ICP ${c.avg_icp_of_positive ?? "?"}, ${good} tier-1/2 of ${c.positive}` +
692
- (steps ? `\n${steps}` : "");
693
+ ` positive repliers: avg ICP ${c.avg_icp_of_positive ?? "?"}, ${good} tier-1/2 of ${cap.positive}` +
694
+ (steps ? `\n${steps}` : "") + obj;
693
695
  });
694
- const head = `Campaign performance (${t.conversions || 0} clients, ${t.positive || 0}/${t.replies || 0} replies positive, ${pct(t.positive_rate)}):`;
696
+ const head = `Campaign performance (${t.conversions || 0} clients · ${t.sent || 0} sent · ${t.positive || 0}/${t.replies || 0} captured replies positive):`;
695
697
  return { content: [{ type: "text", text: `${head}\n${lines.join("\n")}` }] };
696
698
  }
697
699
  );
@@ -712,11 +714,28 @@ export function createServer() {
712
714
  const r = await get("/v2/pipeline/intelligence", {});
713
715
  const pct = (n) => n == null ? "—" : `${Math.round(n * 100)}%`;
714
716
  const cc = r.current_stage_counts || {};
715
- const order = ["identified", "aware", "connected", "interested", "evaluating", "client", "lost", "disqualified", "churned"];
716
- const dist = order.filter(s => cc[s]).map(s => `${s} ${cc[s]}`).join(" · ") || "no staged accounts";
717
+ // The funnel, lowest highest, then the terminals. SOURCE OF TRUTH:
718
+ // PIPELINE_LADDER + TERMINAL_STAGES in packages/core/src/pipeline.ts. This
719
+ // package publishes standalone and has no @nous/core dependency, so the
720
+ // list is copied here. Keep it in step with core.
721
+ const order = [
722
+ "identified", "connected", "interested", "meeting_booked",
723
+ "discovery", "demo", "negotiation", "closed_won",
724
+ "closed_lost", "disqualified", "churned",
725
+ ];
726
+ // Ladder order first, then any stage the API sent that this copy doesn't
727
+ // know about. The old list had no proposal, negotiation or demo in it and
728
+ // the filter silently dropped everything it missed, so late-funnel accounts
729
+ // disappeared from the distribution entirely. A stage must never vanish
730
+ // just because a copy of the list went stale.
731
+ const byStage = (obj, keep) => [
732
+ ...order.filter(s => keep(obj[s])),
733
+ ...Object.keys(obj).filter(s => !order.includes(s) && keep(obj[s])),
734
+ ];
735
+ const dist = byStage(cc, v => !!v).map(s => `${s} ${cc[s]}`).join(" · ") || "no staged accounts";
717
736
  const conv = (r.stage_conversion || []).map(c => ` ${c.from} → ${c.to}: ${pct(c.rate)} (${c.reached_to}/${c.reached_from})`).join("\n");
718
737
  const dis = r.median_days_in_stage || {};
719
- const inStage = order.filter(s => dis[s] != null).map(s => `${s} ${dis[s]}d`).join(" · ");
738
+ const inStage = byStage(dis, v => v != null).map(s => `${s} ${dis[s]}d`).join(" · ");
720
739
  const text = `Pipeline (${r.accounts || 0} accounts, ${r.clients || 0} clients):\n` +
721
740
  ` now: ${dist}\n` +
722
741
  ` median days to client: ${r.median_days_to_client ?? "— (no wins yet)"}\n` +
@@ -726,6 +745,43 @@ export function createServer() {
726
745
  }
727
746
  );
728
747
 
748
+ // ===========================================================================
749
+ // TOOL: pipeline — GET /v2/pipeline/portfolio
750
+ // The whole account portfolio in one call: health/band, ICP fit, stage,
751
+ // days-quiet, open flags, open objections, live competitors, engagement trend,
752
+ // multi-threading. The "reason over my whole book" tool.
753
+ // ===========================================================================
754
+ server.tool(
755
+ "pipeline",
756
+ "The whole ACCOUNT PORTFOLIO in one call — every in-touch account with its deal health + band, " +
757
+ "ICP fit, pipeline stage, days-quiet, open re-engagement flags, open-objection count, live competitors, " +
758
+ "engagement trend (rising/steady/cooling/cold), and multi-threading, plus a stage-count breakdown. Use " +
759
+ "for any portfolio question: 'which accounts are cooling', 'who should I re-engage', 'where is competitive " +
760
+ "risk highest', 'what's slipping', 'great-fit deals gone cold', 'analyze my pipeline'. Reason over the list.",
761
+ { limit: z.number().optional().describe("Cap on accounts returned (default: all, most-actionable first)") },
762
+ async (input) => {
763
+ const r = await get("/v2/pipeline/portfolio", input.limit ? { limit: input.limit } : {});
764
+ const stages = Object.entries(r.stage_counts || {}).map(([s, n]) => `${s} ${n}`).join(" · ");
765
+ const rows = (r.accounts || []).slice(0, input.limit || 60).map(a => {
766
+ const bits = [
767
+ a.name,
768
+ `health ${a.health ?? "—"} (${a.band})`,
769
+ a.icp != null ? `ICP ${a.icp}` : null,
770
+ `stage ${a.stage}`,
771
+ a.days_quiet != null ? `quiet ${a.days_quiet}d` : null,
772
+ `engagement ${a.engagement_trend}`,
773
+ a.single_threaded ? "single-threaded" : `${a.engaged_contacts}/${a.total_contacts} engaged`,
774
+ a.open_objections ? `${a.open_objections} open objection${a.open_objections === 1 ? "" : "s"}` : null,
775
+ a.competitors?.length ? `competitors: ${a.competitors.join(", ")}` : null,
776
+ a.flags?.length ? `flags: ${a.flags.join(", ")}` : null,
777
+ ].filter(Boolean);
778
+ return ` • ${bits.join(" — ")}`;
779
+ }).join("\n");
780
+ const text = `Portfolio (${r.total || 0} in-touch accounts). By stage: ${stages || "—"}\n${rows || " (none)"}`;
781
+ return { content: [{ type: "text", text }] };
782
+ }
783
+ );
784
+
729
785
  // ===========================================================================
730
786
  // TOOL: get_action_items — GET /v2/action-items
731
787
  // Commitments extracted from meetings/emails — what you owe each account.
@@ -798,10 +854,6 @@ export function createServer() {
798
854
  }
799
855
  );
800
856
 
801
- // get_gtm_profile removed: the user's GTM lives in their files, mirrored into
802
- // the graph as foundations (get_foundation) plus the learned ICP model. Read
803
- // get_foundation for the user's own rules, ICP, and positioning.
804
-
805
857
  // ===========================================================================
806
858
  // TOOLS: get_foundation / sync_foundation — the POLICY layer (vs. facts).
807
859
  // Foundations are versioned rule-docs that GOVERN agent behavior: voice, outreach,
@@ -889,12 +941,6 @@ export function createServer() {
889
941
  "category to list all four.",
890
942
  getInsightsSchema, getInsightsHandler);
891
943
 
892
- // The GTM context is no longer written through a dedicated MCP tool. In the file
893
- // symbiosis model the user's own files (context/icp.md, positioning.md, …) are
894
- // the source of truth: the agent edits those with its own file tools and calls
895
- // `sync_icp` to sync them into the graph (and `export_icp_model` to write the learned
896
- // model back). The shared POST /v2/workspace/facts route still backs that import.
897
-
898
944
  // ===========================================================================
899
945
  // TOOL: save_note — POST /v2/notes
900
946
  // Attach a long-form artifact to a CONTACT: a meeting brief you wrote, a
@@ -928,6 +974,36 @@ export function createServer() {
928
974
  },
929
975
  );
930
976
 
977
+ // ===========================================================================
978
+ // TOOL: propose_vault_file — POST /v2/personal/propose
979
+ // Propose a markdown file into the member's PERSONAL vault. It lands in their
980
+ // inbox as a proposal — the member approves it before it is filed into a folder
981
+ // or synced to their Git. This is how an agent contributes to a member's own
982
+ // notes (thoughts, decisions, briefs, content) without writing anything without
983
+ // consent. Distinct from `save_note` (which attaches a document to a CONTACT's
984
+ // record) — this is the member's private vault, not an account.
985
+ // ===========================================================================
986
+ server.tool(
987
+ "propose_vault_file",
988
+ "Propose a markdown file into the member's personal vault (their private notes, not an " +
989
+ "account). It lands in their INBOX as a proposal — the member approves it before it's filed " +
990
+ "into a folder and synced to their Git; nothing is written without their approval. Use this to " +
991
+ "draft a thought, a decision record, a brief, or content for the member to review and keep. " +
992
+ "Pick the destination folder. This is NOT for notes on a contact (use save_note) and NOT for the " +
993
+ "GTM profile (use sync_icp).",
994
+ {
995
+ folder: z.enum(["inbox", "thoughts", "decisions", "projects", "briefs", "content", "company"])
996
+ .describe("Where it files once approved. ROUTING: briefs = a one-off account or meeting brief, filed FLAT (no subfolder). projects = ongoing recurring analysis by area (with a subfolder): pipeline/funnel review -> projects, subfolder 'pipeline'; campaign analysis -> projects, subfolder 'campaigns'; an initiative -> projects, subfolder = its name. content = anything to publish/send (post, newsletter, outbound copy, market/research write-up). decisions = a decision. thoughts = a loose idea. company = the company's own context. Don't pick 'inbox' — that's where it lands to await approval."),
997
+ 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'."),
998
+ content: z.string().describe("The full markdown content of the file."),
999
+ subfolder: z.string().optional().describe("REQUIRED for 'projects' — the area: 'pipeline', 'campaigns', or the initiative name. Leave empty for 'briefs' (flat) and other folders."),
1000
+ },
1001
+ async ({ folder, name, content, subfolder }) => {
1002
+ await post("/v2/personal/propose", { folder, name, content, subfolder });
1003
+ return { content: [{ type: "text", text: `Proposed "${name}" into ${folder}. It's waiting in the member's inbox for approval.` }] };
1004
+ },
1005
+ );
1006
+
931
1007
  // ===========================================================================
932
1008
  // TOOL: search_notes — POST /v2/notes/search
933
1009
  // Semantic search over saved notes & documents (briefs, transcripts, notes).
@@ -1474,6 +1550,108 @@ export function createServer() {
1474
1550
  }
1475
1551
  );
1476
1552
 
1553
+ // ===========================================================================
1554
+ // TOOL: send_linkedin_message — the ACT layer. An OUTWARD ACTION: it goes out
1555
+ // through Unipile immediately, it is not a draft. The agent should read the
1556
+ // 'voice'/'outreach' foundations first and `record` the touch after. Derives the
1557
+ // workspace from the API key and runs on the workspace's connected LinkedIn
1558
+ // account. POST /api/linkedin/send-message (verifyAuthEither).
1559
+ // ===========================================================================
1560
+ server.tool(
1561
+ "send_linkedin_message",
1562
+ "SEND a LinkedIn direct message for real, right now, from the workspace's connected LinkedIn " +
1563
+ "account. This is an OUTWARD ACTION, not a draft — it reaches the person the moment you call it, so " +
1564
+ "only send a message the user has approved. Address it one of three ways: `linkedin_url` (their " +
1565
+ "profile URL) or `linkedin_member_id` to start a new conversation, or `chat_id` to REPLY inside an " +
1566
+ "existing thread. It returns the thread's `chat_id` — keep it so the next reply stays in the same " +
1567
+ "thread. Best practice: read the 'voice' and 'outreach' foundations first so the message obeys the " +
1568
+ "user's rules, and `record` the touch afterward so the graph stays current. Requires a connected " +
1569
+ "LinkedIn account (Integrations); it says so if none is connected.",
1570
+ {
1571
+ text: z.string().min(1).describe("The message body to send."),
1572
+ linkedin_url: z.string().optional().describe("The recipient's LinkedIn profile URL. Provide this OR linkedin_member_id (to start a new chat), OR chat_id (to reply in an existing thread)."),
1573
+ linkedin_member_id: z.string().optional().describe("The recipient's LinkedIn member id, if you have it instead of a profile URL."),
1574
+ chat_id: z.string().optional().describe("An existing conversation's chat_id — pass it to reply in-thread instead of opening a new chat."),
1575
+ },
1576
+ async ({ text, linkedin_url, linkedin_member_id, chat_id }) => {
1577
+ if (!linkedin_url && !linkedin_member_id && !chat_id) {
1578
+ return { content: [{ type: "text", text:
1579
+ "Tell me who to message: pass linkedin_url or linkedin_member_id to start a new chat, or chat_id to reply in an existing thread." }] };
1580
+ }
1581
+ try {
1582
+ const r = await post("/api/linkedin/send-message", { text, linkedin_url, linkedin_member_id, chat_id });
1583
+ return { content: [{ type: "text", text:
1584
+ `Message sent over LinkedIn.${r.chat_id ? ` Thread chat_id: ${r.chat_id} (reuse it to reply in-thread).` : ""}` }] };
1585
+ } catch (e) {
1586
+ const msg =
1587
+ /linkedin_not_connected/.test(e.message)
1588
+ ? "No LinkedIn account is connected. Tell the user to connect LinkedIn in Integrations first."
1589
+ : /auth_required/.test(e.message)
1590
+ ? "This API key isn't scoped to a workspace, or LinkedIn isn't connected."
1591
+ : /missing_params/.test(e.message)
1592
+ ? "Couldn't tell who to message — provide linkedin_url, linkedin_member_id, or chat_id."
1593
+ : `Couldn't send the message: ${e.message}`;
1594
+ return { content: [{ type: "text", text: msg }] };
1595
+ }
1596
+ }
1597
+ );
1598
+
1599
+ // ===========================================================================
1600
+ // TOOLS: the CORRECTION layer — unsay something recorded by mistake. `record` and
1601
+ // `save_note` are how you write; these are how you take it back. Both heal the
1602
+ // derived layer: retracting an observation re-derives the claim from what remains,
1603
+ // deleting a note drops it from search/context. DELETE /v2/observations|notes/:id.
1604
+ // ===========================================================================
1605
+ server.tool(
1606
+ "retract_observation",
1607
+ "RETRACT an observation you recorded by mistake, and heal the record. Pass the observation's " +
1608
+ "`id` (returned by `record`). Nous deletes it and re-derives the affected fact from the " +
1609
+ "observations that remain — so a wrong value you observed is un-observed and the claim reverts as " +
1610
+ "if it had never happened; if it was the only observation for that fact, the fact is invalidated. " +
1611
+ "Use this when you recorded the wrong thing (wrong value, wrong person, a test), NOT to represent a " +
1612
+ "real change over time — a genuine change is a NEW `record`, which supersedes by recency.",
1613
+ {
1614
+ id: z.string().describe("The observation id to retract (from a prior `record` result)."),
1615
+ },
1616
+ async ({ id }) => {
1617
+ try {
1618
+ const r = await del(`/v2/observations/${encodeURIComponent(id)}`);
1619
+ return { content: [{ type: "text", text:
1620
+ `Observation retracted. The claim for ${r.property} was ${r.claim === "invalidated" ? "invalidated (no observations left)" : "re-derived from the remaining observations"}.` }] };
1621
+ } catch (e) {
1622
+ const msg = /observation_not_found/.test(e.message)
1623
+ ? "No observation with that id in this workspace — check the id from the record result."
1624
+ : `Couldn't retract the observation: ${e.message}`;
1625
+ return { content: [{ type: "text", text: msg }] };
1626
+ }
1627
+ }
1628
+ );
1629
+
1630
+ server.tool(
1631
+ "delete_note",
1632
+ "DELETE a note saved by mistake. Pass the note's `id` (returned by `save_note`). It's removed from " +
1633
+ "search and context immediately; the timeline stays reconstructable. Only touches notes — it will " +
1634
+ "refuse an id that isn't a note. Use this for a note saved in error; to CHANGE a note, save a new " +
1635
+ "one instead.",
1636
+ {
1637
+ id: z.string().describe("The note id to delete (from a prior `save_note` result)."),
1638
+ },
1639
+ async ({ id }) => {
1640
+ try {
1641
+ const r = await del(`/v2/notes/${encodeURIComponent(id)}`);
1642
+ return { content: [{ type: "text", text:
1643
+ r.status === "already_deleted" ? "That note was already deleted." : "Note deleted — it's out of search and context now." }] };
1644
+ } catch (e) {
1645
+ const msg = /note_not_found/.test(e.message)
1646
+ ? "No note with that id in this workspace — check the id from the save_note result."
1647
+ : /not_a_note/.test(e.message)
1648
+ ? "That id isn't a note, so it can't be deleted here. Only save_note notes can be deleted this way."
1649
+ : `Couldn't delete the note: ${e.message}`;
1650
+ return { content: [{ type: "text", text: msg }] };
1651
+ }
1652
+ }
1653
+ );
1654
+
1477
1655
  // ===========================================================================
1478
1656
  // TOOL: get_routing_preferences
1479
1657
  // The routing preferences that make THIS agent default to Nous for GTM. The