@opennous/mcp 0.46.0 → 0.47.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.47.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,23 @@
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
+ * RUN get_workspace_status · set_workspace_profile · build_icp_model · train_icp_model ·
21
+ * sync_icp · export_icp_model · connect_integration · configure_crm_sync ·
22
+ * sync_crm_now · scrape_engagers · get_routing_preferences
34
23
  */
35
24
 
36
25
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
37
26
  import { z } from "zod";
38
27
  import { get, post } from "./client.js";
39
28
 
40
- export const SERVER_VERSION = "0.42.0";
29
+ export const SERVER_VERSION = "0.47.0";
41
30
 
42
31
  // ─── helpers ──────────────────────────────────────────────────────────────────
43
32
 
@@ -508,7 +497,7 @@ export function createServer() {
508
497
  "Retrieve and summarise activity across many people. Three powers:\n" +
509
498
  " 1. return:'entities' groups results by person/company (one row per entity, ranked by " +
510
499
  "most-recent matching activity). Use for 'hottest leads', 'who replied this week', " +
511
- "'who's in evaluating stage'.\n" +
500
+ "'who's in the negotiation stage'.\n" +
512
501
  " 2. `without` subtracts entities — 'sent in 5d MINUS replied in 5d' = 'no-reply leads'. " +
513
502
  "'activity in 30d MINUS activity in 5d' = 'cooled leads'.\n" +
514
503
  " 3. rollups.by_value appears when scope.kind='state' — counts entities by current value " +
@@ -679,19 +668,31 @@ export function createServer() {
679
668
  const camps = r.campaigns || [];
680
669
  if (!camps.length) return { content: [{ type: "text", text: "No campaign replies logged yet." }] };
681
670
  const t = r.totals || {};
682
- const pct = (n) => `${Math.round((n || 0) * 100)}%`;
671
+ const pct = (n) => n == null ? "—" : `${Math.round(n * 100)}%`;
683
672
  const lines = camps.map(c => {
673
+ const cap = c.captured || {};
674
+ const pt = c.provider_totals, pr = c.provider_rates;
684
675
  const tiers = c.tier_of_positive || {};
685
676
  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");
677
+ const funnel = pt
678
+ ? ` funnel: ${pt.sent ?? "?"} sent ${pt.contacted ?? "?"} contacted → ${pt.replies ?? "?"} replies (${pct(pr?.reply_rate)}) → ${pt.opportunities ?? "?"} opportunities (${pct(pr?.opportunity_rate)})`
679
+ : ` funnel: (no provider analytics yet)`;
680
+ 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` : ""}`;
681
+ const steps = (c.steps || []).map(s => {
682
+ const subj = s.subject ? ` — "${s.subject}"` : "";
683
+ return ` step ${s.step ?? "—"}${subj}: ${s.positive}/${s.replies} positive (${pct(s.positive_rate)})`;
684
+ }).join("\n");
685
+ const obj = (c.top_objections || []).length
686
+ ? `\n objections/pains: ${c.top_objections.slice(0, 4).map(o => `“${String(o).slice(0, 90)}”`).join("; ")}`
687
+ : "";
688
688
  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` +
689
+ funnel + "\n" +
690
+ ` captured sample: ${cap.replies} replies — ${cap.positive} positive (${pct(cap.positive_rate)}), ${cap.neutral} neutral, ${cap.negative} negative\n` +
690
691
  ` converted: ${conv}\n` +
691
- ` positive repliers: avg ICP ${c.avg_icp_of_positive ?? "?"}, ${good} tier-1/2 of ${c.positive}` +
692
- (steps ? `\n${steps}` : "");
692
+ ` positive repliers: avg ICP ${c.avg_icp_of_positive ?? "?"}, ${good} tier-1/2 of ${cap.positive}` +
693
+ (steps ? `\n${steps}` : "") + obj;
693
694
  });
694
- const head = `Campaign performance (${t.conversions || 0} clients, ${t.positive || 0}/${t.replies || 0} replies positive, ${pct(t.positive_rate)}):`;
695
+ const head = `Campaign performance (${t.conversions || 0} clients · ${t.sent || 0} sent · ${t.positive || 0}/${t.replies || 0} captured replies positive):`;
695
696
  return { content: [{ type: "text", text: `${head}\n${lines.join("\n")}` }] };
696
697
  }
697
698
  );
@@ -712,11 +713,28 @@ export function createServer() {
712
713
  const r = await get("/v2/pipeline/intelligence", {});
713
714
  const pct = (n) => n == null ? "—" : `${Math.round(n * 100)}%`;
714
715
  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";
716
+ // The funnel, lowest highest, then the terminals. SOURCE OF TRUTH:
717
+ // PIPELINE_LADDER + TERMINAL_STAGES in packages/core/src/pipeline.ts. This
718
+ // package publishes standalone and has no @nous/core dependency, so the
719
+ // list is copied here. Keep it in step with core.
720
+ const order = [
721
+ "identified", "connected", "interested", "meeting_booked",
722
+ "discovery", "demo", "negotiation", "closed_won",
723
+ "closed_lost", "disqualified", "churned",
724
+ ];
725
+ // Ladder order first, then any stage the API sent that this copy doesn't
726
+ // know about. The old list had no proposal, negotiation or demo in it and
727
+ // the filter silently dropped everything it missed, so late-funnel accounts
728
+ // disappeared from the distribution entirely. A stage must never vanish
729
+ // just because a copy of the list went stale.
730
+ const byStage = (obj, keep) => [
731
+ ...order.filter(s => keep(obj[s])),
732
+ ...Object.keys(obj).filter(s => !order.includes(s) && keep(obj[s])),
733
+ ];
734
+ const dist = byStage(cc, v => !!v).map(s => `${s} ${cc[s]}`).join(" · ") || "no staged accounts";
717
735
  const conv = (r.stage_conversion || []).map(c => ` ${c.from} → ${c.to}: ${pct(c.rate)} (${c.reached_to}/${c.reached_from})`).join("\n");
718
736
  const dis = r.median_days_in_stage || {};
719
- const inStage = order.filter(s => dis[s] != null).map(s => `${s} ${dis[s]}d`).join(" · ");
737
+ const inStage = byStage(dis, v => v != null).map(s => `${s} ${dis[s]}d`).join(" · ");
720
738
  const text = `Pipeline (${r.accounts || 0} accounts, ${r.clients || 0} clients):\n` +
721
739
  ` now: ${dist}\n` +
722
740
  ` median days to client: ${r.median_days_to_client ?? "— (no wins yet)"}\n` +
@@ -726,6 +744,43 @@ export function createServer() {
726
744
  }
727
745
  );
728
746
 
747
+ // ===========================================================================
748
+ // TOOL: pipeline — GET /v2/pipeline/portfolio
749
+ // The whole account portfolio in one call: health/band, ICP fit, stage,
750
+ // days-quiet, open flags, open objections, live competitors, engagement trend,
751
+ // multi-threading. The "reason over my whole book" tool.
752
+ // ===========================================================================
753
+ server.tool(
754
+ "pipeline",
755
+ "The whole ACCOUNT PORTFOLIO in one call — every in-touch account with its deal health + band, " +
756
+ "ICP fit, pipeline stage, days-quiet, open re-engagement flags, open-objection count, live competitors, " +
757
+ "engagement trend (rising/steady/cooling/cold), and multi-threading, plus a stage-count breakdown. Use " +
758
+ "for any portfolio question: 'which accounts are cooling', 'who should I re-engage', 'where is competitive " +
759
+ "risk highest', 'what's slipping', 'great-fit deals gone cold', 'analyze my pipeline'. Reason over the list.",
760
+ { limit: z.number().optional().describe("Cap on accounts returned (default: all, most-actionable first)") },
761
+ async (input) => {
762
+ const r = await get("/v2/pipeline/portfolio", input.limit ? { limit: input.limit } : {});
763
+ const stages = Object.entries(r.stage_counts || {}).map(([s, n]) => `${s} ${n}`).join(" · ");
764
+ const rows = (r.accounts || []).slice(0, input.limit || 60).map(a => {
765
+ const bits = [
766
+ a.name,
767
+ `health ${a.health ?? "—"} (${a.band})`,
768
+ a.icp != null ? `ICP ${a.icp}` : null,
769
+ `stage ${a.stage}`,
770
+ a.days_quiet != null ? `quiet ${a.days_quiet}d` : null,
771
+ `engagement ${a.engagement_trend}`,
772
+ a.single_threaded ? "single-threaded" : `${a.engaged_contacts}/${a.total_contacts} engaged`,
773
+ a.open_objections ? `${a.open_objections} open objection${a.open_objections === 1 ? "" : "s"}` : null,
774
+ a.competitors?.length ? `competitors: ${a.competitors.join(", ")}` : null,
775
+ a.flags?.length ? `flags: ${a.flags.join(", ")}` : null,
776
+ ].filter(Boolean);
777
+ return ` • ${bits.join(" — ")}`;
778
+ }).join("\n");
779
+ const text = `Portfolio (${r.total || 0} in-touch accounts). By stage: ${stages || "—"}\n${rows || " (none)"}`;
780
+ return { content: [{ type: "text", text }] };
781
+ }
782
+ );
783
+
729
784
  // ===========================================================================
730
785
  // TOOL: get_action_items — GET /v2/action-items
731
786
  // Commitments extracted from meetings/emails — what you owe each account.
@@ -798,10 +853,6 @@ export function createServer() {
798
853
  }
799
854
  );
800
855
 
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
856
  // ===========================================================================
806
857
  // TOOLS: get_foundation / sync_foundation — the POLICY layer (vs. facts).
807
858
  // Foundations are versioned rule-docs that GOVERN agent behavior: voice, outreach,
@@ -889,12 +940,6 @@ export function createServer() {
889
940
  "category to list all four.",
890
941
  getInsightsSchema, getInsightsHandler);
891
942
 
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
943
  // ===========================================================================
899
944
  // TOOL: save_note — POST /v2/notes
900
945
  // Attach a long-form artifact to a CONTACT: a meeting brief you wrote, a
@@ -928,6 +973,36 @@ export function createServer() {
928
973
  },
929
974
  );
930
975
 
976
+ // ===========================================================================
977
+ // TOOL: propose_vault_file — POST /v2/personal/propose
978
+ // Propose a markdown file into the member's PERSONAL vault. It lands in their
979
+ // inbox as a proposal — the member approves it before it is filed into a folder
980
+ // or synced to their Git. This is how an agent contributes to a member's own
981
+ // notes (thoughts, decisions, briefs, content) without writing anything without
982
+ // consent. Distinct from `save_note` (which attaches a document to a CONTACT's
983
+ // record) — this is the member's private vault, not an account.
984
+ // ===========================================================================
985
+ server.tool(
986
+ "propose_vault_file",
987
+ "Propose a markdown file into the member's personal vault (their private notes, not an " +
988
+ "account). It lands in their INBOX as a proposal — the member approves it before it's filed " +
989
+ "into a folder and synced to their Git; nothing is written without their approval. Use this to " +
990
+ "draft a thought, a decision record, a brief, or content for the member to review and keep. " +
991
+ "Pick the destination folder. This is NOT for notes on a contact (use save_note) and NOT for the " +
992
+ "GTM profile (use sync_icp).",
993
+ {
994
+ folder: z.enum(["inbox", "thoughts", "decisions", "projects", "briefs", "content", "company"])
995
+ .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."),
996
+ 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'."),
997
+ content: z.string().describe("The full markdown content of the file."),
998
+ subfolder: z.string().optional().describe("REQUIRED for 'projects' — the area: 'pipeline', 'campaigns', or the initiative name. Leave empty for 'briefs' (flat) and other folders."),
999
+ },
1000
+ async ({ folder, name, content, subfolder }) => {
1001
+ await post("/v2/personal/propose", { folder, name, content, subfolder });
1002
+ return { content: [{ type: "text", text: `Proposed "${name}" into ${folder}. It's waiting in the member's inbox for approval.` }] };
1003
+ },
1004
+ );
1005
+
931
1006
  // ===========================================================================
932
1007
  // TOOL: search_notes — POST /v2/notes/search
933
1008
  // Semantic search over saved notes & documents (briefs, transcripts, notes).
@@ -1474,6 +1549,52 @@ export function createServer() {
1474
1549
  }
1475
1550
  );
1476
1551
 
1552
+ // ===========================================================================
1553
+ // TOOL: send_linkedin_message — the ACT layer. An OUTWARD ACTION: it goes out
1554
+ // through Unipile immediately, it is not a draft. The agent should read the
1555
+ // 'voice'/'outreach' foundations first and `record` the touch after. Derives the
1556
+ // workspace from the API key and runs on the workspace's connected LinkedIn
1557
+ // account. POST /api/linkedin/send-message (verifyAuthEither).
1558
+ // ===========================================================================
1559
+ server.tool(
1560
+ "send_linkedin_message",
1561
+ "SEND a LinkedIn direct message for real, right now, from the workspace's connected LinkedIn " +
1562
+ "account. This is an OUTWARD ACTION, not a draft — it reaches the person the moment you call it, so " +
1563
+ "only send a message the user has approved. Address it one of three ways: `linkedin_url` (their " +
1564
+ "profile URL) or `linkedin_member_id` to start a new conversation, or `chat_id` to REPLY inside an " +
1565
+ "existing thread. It returns the thread's `chat_id` — keep it so the next reply stays in the same " +
1566
+ "thread. Best practice: read the 'voice' and 'outreach' foundations first so the message obeys the " +
1567
+ "user's rules, and `record` the touch afterward so the graph stays current. Requires a connected " +
1568
+ "LinkedIn account (Integrations); it says so if none is connected.",
1569
+ {
1570
+ text: z.string().min(1).describe("The message body to send."),
1571
+ 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)."),
1572
+ linkedin_member_id: z.string().optional().describe("The recipient's LinkedIn member id, if you have it instead of a profile URL."),
1573
+ chat_id: z.string().optional().describe("An existing conversation's chat_id — pass it to reply in-thread instead of opening a new chat."),
1574
+ },
1575
+ async ({ text, linkedin_url, linkedin_member_id, chat_id }) => {
1576
+ if (!linkedin_url && !linkedin_member_id && !chat_id) {
1577
+ return { content: [{ type: "text", text:
1578
+ "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." }] };
1579
+ }
1580
+ try {
1581
+ const r = await post("/api/linkedin/send-message", { text, linkedin_url, linkedin_member_id, chat_id });
1582
+ return { content: [{ type: "text", text:
1583
+ `Message sent over LinkedIn.${r.chat_id ? ` Thread chat_id: ${r.chat_id} (reuse it to reply in-thread).` : ""}` }] };
1584
+ } catch (e) {
1585
+ const msg =
1586
+ /linkedin_not_connected/.test(e.message)
1587
+ ? "No LinkedIn account is connected. Tell the user to connect LinkedIn in Integrations first."
1588
+ : /auth_required/.test(e.message)
1589
+ ? "This API key isn't scoped to a workspace, or LinkedIn isn't connected."
1590
+ : /missing_params/.test(e.message)
1591
+ ? "Couldn't tell who to message — provide linkedin_url, linkedin_member_id, or chat_id."
1592
+ : `Couldn't send the message: ${e.message}`;
1593
+ return { content: [{ type: "text", text: msg }] };
1594
+ }
1595
+ }
1596
+ );
1597
+
1477
1598
  // ===========================================================================
1478
1599
  // TOOL: get_routing_preferences
1479
1600
  // The routing preferences that make THIS agent default to Nous for GTM. The