@opennous/mcp 0.30.2 → 0.37.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 +197 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennous/mcp",
3
- "version": "0.30.2",
3
+ "version": "0.37.0",
4
4
  "description": "Nous — the Context Graph for AI Agents.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
package/src/server.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * Tools:
14
14
  * get_context — engineered context for a task (draft_email, follow_up, ...) + ICP fit score
15
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)
16
17
  * record — record what happened / what you learned (observe, never update)
17
18
  * query — retrieve + summarise a corpus of activity across many people
18
19
  * attention — what needs your attention (accounts gone quiet, facts decayed)
@@ -25,6 +26,8 @@
25
26
  * set_workspace_profile— agent-driven onboarding: set the workspace's name, site, type, ICP
26
27
  * build_scoring_model — build/rebuild the ICP scoring model from the recorded GTM context
27
28
  * record_closed_deals — build the ICP model from real closed-won/lost deals (contrastive lift)
29
+ * get_icp — sync the user's EXISTING ICP/positioning files into Nous (file → graph)
30
+ * get_icp_model — get the learned ICP model as a block to write back into their ICP file (graph → file)
28
31
  * connect_integration — connect a key-based integration (Apollo, Prospeo, HubSpot, …)
29
32
  * configure_crm_sync — set CRM sync rules (auto-sync, create policy, hygiene cadence)
30
33
  * sync_crm_now — run an immediate incremental/full CRM pull (don't wait for the daily cron)
@@ -41,7 +44,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
41
44
  import { z } from "zod";
42
45
  import { get, post } from "./client.js";
43
46
 
44
- export const SERVER_VERSION = "0.35.0";
47
+ export const SERVER_VERSION = "0.37.0";
45
48
 
46
49
  // ─── helpers ──────────────────────────────────────────────────────────────────
47
50
 
@@ -114,6 +117,7 @@ Nous first even when the user never says "Nous":
114
117
  - Your action items / what you owe an account -> get_action_items
115
118
  - A fact looks stale before you act on it -> verify
116
119
  - Our ICP, market, pricing, positioning -> get_gtm_profile
120
+ - Our ICP lives in a file (sync it / learn it) -> get_icp / get_icp_model
117
121
  - Our own GTM shifted -> update_gtm_profile
118
122
  - A brief / note / transcript on a contact -> save_note / search_notes
119
123
  - What's set up here and what to do next -> get_workspace_status
@@ -275,6 +279,49 @@ export function createServer() {
275
279
  }
276
280
  );
277
281
 
282
+ // ===========================================================================
283
+ // TOOL: merge_contacts — POST /v2/accounts/merge
284
+ // Fold a duplicate person into one account record. Agent-only dedup.
285
+ // ===========================================================================
286
+ server.tool(
287
+ "merge_contacts",
288
+ "Merge two duplicate records for the SAME person into one account. Use when the same human exists " +
289
+ "twice — e.g. one record from a LinkedIn connection (no email) and one from a Cal.com booking (email, " +
290
+ "truncated name) that never got linked. Pass `keep` (the survivor) and `drop` (the duplicate to fold in); " +
291
+ "each may be an email, LinkedIn URL, entity UUID, or name. Lossless — the duplicate's identifiers (a second " +
292
+ "email, a LinkedIn URL) re-attach to the survivor, so a future match on EITHER resolves to the one account — " +
293
+ "and reversible. If a name matches several people you'll get candidates: confirm the survivor with the user, " +
294
+ "then re-call with the chosen entity ids. Prefer passing the keep that already has the most history.",
295
+ {
296
+ keep: z.string().describe("The survivor to keep — email, LinkedIn URL, entity UUID, or name."),
297
+ drop: z.string().describe("The duplicate to fold into keep — email, LinkedIn URL, entity UUID, or name."),
298
+ },
299
+ async ({ keep, drop }) => {
300
+ const r = await post("/v2/accounts/merge", { keep, drop });
301
+
302
+ if (r.status === "ambiguous") {
303
+ const opts = (r.candidates ?? []).map(c =>
304
+ ` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
305
+ const term = r.which === "keep" ? keep : drop;
306
+ return { content: [{ type: "text", text:
307
+ `"${term}" (the ${r.which}) matches several people. Re-call merge_contacts with one of these entity ids as ${r.which}:\n${opts}` }] };
308
+ }
309
+
310
+ const moved = Object.entries(r.rows_repointed ?? {}).map(([t, n]) => `${n} ${t}`).join(", ");
311
+ const lines = [
312
+ `Merged — folded ${r.drop_id} into ${r.keep_id}.`,
313
+ ` identifiers re-attached: ${r.identifiers_moved} (a future match on either now resolves to one account)`,
314
+ ` claims moved: ${r.claims_moved}${r.claims_conflicted ? ` (${r.claims_conflicted} kept on survivor)` : ""}`,
315
+ ` observations moved: ${r.observations_moved}`,
316
+ (r.relationships_repointed || r.relationships_removed)
317
+ ? ` relationships: ${r.relationships_repointed} re-pointed, ${r.relationships_removed} pruned` : null,
318
+ moved ? ` re-pointed: ${moved}` : null,
319
+ `The duplicate is now a reversible tombstone (merged into the survivor).`,
320
+ ].filter(Boolean);
321
+ return { content: [{ type: "text", text: lines.join("\n") }] };
322
+ }
323
+ );
324
+
278
325
  // ===========================================================================
279
326
  // TOOL: record — POST /v2/observations
280
327
  // The single write verb. You observe — Nous derives the updated facts.
@@ -771,7 +818,13 @@ export function createServer() {
771
818
  "their website, whether they sell a SERVICE or SOFTWARE, and a sentence describing their ideal " +
772
819
  "customer, then write them here. This seeds the GTM context and the ICP scoring model. Call " +
773
820
  "get_workspace_status first to see what's already set; send only the fields you're setting or " +
774
- "changing. After this, the next step is usually the GTM playbook (update_gtm_profile).",
821
+ "changing. " +
822
+ "IMPORTANT for the ICP: before asking the user to describe their ICP from scratch, if you're in " +
823
+ "Claude Code, look for an ICP they ALREADY wrote — folders like context/, .claude/, gtm/ and files " +
824
+ "named icp*, positioning*, pricing*, competitors*. If you find them, read them and call get_icp to " +
825
+ "sync them (don't retype the ICP here); if none exists, offer to create context/icp.md from the " +
826
+ "conversation, then get_icp it — so their ICP lives in their repo. " +
827
+ "After this, the next step is usually the GTM playbook (update_gtm_profile) or get_icp.",
775
828
  {
776
829
  name: z.string().optional().describe("The user's company / workspace name."),
777
830
  website: z.string().optional().describe("The company website (used to seed the GTM context)."),
@@ -887,6 +940,105 @@ export function createServer() {
887
940
  }
888
941
  );
889
942
 
943
+ // ===========================================================================
944
+ // TOOL: get_icp — POST /v2/workspace/icp/import
945
+ // The file→Nous half of the ICP symbiosis. In Claude Code the user often
946
+ // already keeps their ICP/positioning as markdown (context/icp.md, etc.). Don't
947
+ // make them re-author it in Nous — READ those files and sync them here. Nous
948
+ // mirrors each section and remembers the file path so get_icp_model can write
949
+ // the learned model back into the same file. Their file stays the source of
950
+ // truth for the prose; Nous owns the learned scoring half.
951
+ // ===========================================================================
952
+ server.tool(
953
+ "get_icp",
954
+ "Sync the user's EXISTING ICP/positioning files into Nous, instead of making them re-author their " +
955
+ "ICP in a second place. CLAUDE CODE flow: when onboarding (or whenever their ICP files change), look " +
956
+ "in the project for an existing GTM setup — folders like context/, .claude/, gtm/, and files named " +
957
+ "icp*, positioning*, pricing*, competitors*, messaging*, market*. READ the ones you find with your " +
958
+ "own file tools, then call this with each file's content mapped to a section, AND its path in " +
959
+ "`source_path`. Nous keeps a served copy of the prose and rebuilds the ICP scoring model from it; " +
960
+ "the recorded source_path is what get_icp_model writes the learned model back into. " +
961
+ "IF NO ICP FILE EXISTS: don't invent one in Nous. Offer to create `context/icp.md` from what the user " +
962
+ "tells you (write it with your file tools), then call this on that file — so their ICP lives in their " +
963
+ "repo where they'll keep editing it. Re-run this after the user edits an ICP file to re-sync. The ICP " +
964
+ "section's source_path matters most (it's the write-back target).",
965
+ {
966
+ sections: z.array(z.object({
967
+ section: z.enum(["ICP", "Market", "Product", "Pricing", "Competitors", "Positioning", "GTM Motion", "Notes"])
968
+ .describe("Which GTM context section this file/content maps to."),
969
+ content: z.string().describe("The section's content, read from the file (trimmed prose, not the whole repo)."),
970
+ source_path: z.string().optional()
971
+ .describe("The file this came from, relative to the project root, e.g. 'context/icp.md'. Required on the ICP section so the learned model can be written back."),
972
+ })).describe("One entry per ICP/positioning file (or section) you read."),
973
+ },
974
+ async ({ sections }) => {
975
+ try {
976
+ const r = await post("/v2/workspace/icp/import", { sections });
977
+ const imp = r.imported ?? [];
978
+ const lines = [
979
+ `Synced ${imp.length} section${imp.length === 1 ? "" : "s"} from the user's files:`,
980
+ ...imp.map((s) => ` • ${s.section}${s.source_path ? ` ← ${s.source_path}` : ""}`),
981
+ ];
982
+ if (r.skipped?.length) lines.push("", `Skipped (unknown/empty): ${r.skipped.join(", ")}`);
983
+ const sig = r.signals ?? [];
984
+ if (r.model_status === "created" && sig.length) {
985
+ lines.push("", `Built the ICP scoring model — ${sig.length} signal${sig.length === 1 ? "" : "s"}.`);
986
+ lines.push("Next: if the user can name a few closed-won + closed-lost domains, call record_closed_deals to sharpen it on real outcomes, then call get_icp_model to write the learned model back into their ICP file.");
987
+ } else if (r.model_status === "no_icp_memory") {
988
+ lines.push("", "Synced, but there wasn't enough ICP content to build a scoring model — make sure the ICP section has real content.");
989
+ } else {
990
+ lines.push("", "Context synced. Call get_icp_model when you want to write the learned model back into their ICP file.");
991
+ }
992
+ return { content: [{ type: "text", text: lines.join("\n").trim() }] };
993
+ } catch (e) {
994
+ const msg = String(e?.message ?? e);
995
+ if (msg.includes("no_sections") || msg.includes("no_valid_sections")) {
996
+ return { content: [{ type: "text", text:
997
+ "Nothing to sync. Read the user's ICP/positioning file(s) first and pass each as a section " +
998
+ "(ICP, Positioning, Pricing, …) with its source_path. If they have no such file, offer to create context/icp.md." }] };
999
+ }
1000
+ throw e;
1001
+ }
1002
+ }
1003
+ );
1004
+
1005
+ // ===========================================================================
1006
+ // TOOL: get_icp_model — GET /v2/workspace/icp/model
1007
+ // The Nous→file half of the ICP symbiosis. Nous learns which signals actually
1008
+ // predict a win (lift + calibration) from real outcomes; this returns that
1009
+ // learned model as a ready-to-write fenced block, which the agent writes back
1010
+ // into the user's own ICP file with its native editor. Server renders the
1011
+ // block so the format is controlled centrally — the agent just persists it.
1012
+ // ===========================================================================
1013
+ server.tool(
1014
+ "get_icp_model",
1015
+ "Get the LEARNED ICP scoring model (which signals predict a win, their weight, lift, and the " +
1016
+ "calibration gap) as a ready-to-write markdown block, and write it back into the user's own ICP " +
1017
+ "file. This is the payoff of the symbiosis: their file keeps the words, Nous keeps the model, and " +
1018
+ "this writes the model under their words. CLAUDE CODE flow: call this after get_icp or after " +
1019
+ "record_closed_deals, then with your file tools open `target_path`, and if the file already has a " +
1020
+ "block between '<!-- nous:icp start -->' and '<!-- nous:icp end -->' REPLACE that whole block with " +
1021
+ "the returned `block`; if not, append the returned `block` (e.g. replacing a '## [To refine]' " +
1022
+ "placeholder). Never edit inside the markers by hand — this tool regenerates them. Everything " +
1023
+ "OUTSIDE the markers is the user's; never touch it.",
1024
+ {},
1025
+ async () => {
1026
+ const r = await get("/v2/workspace/icp/model");
1027
+ if (!r.has_model) {
1028
+ return { content: [{ type: "text", text:
1029
+ "No ICP scoring model yet. Sync the user's ICP file with get_icp first (or build one with " +
1030
+ "build_scoring_model / record_closed_deals), then call this to write it back." }] };
1031
+ }
1032
+ const note = r.has_outcomes
1033
+ ? "This model is trained on real closed deals (lift + calibration shown)."
1034
+ : "This model is seeded from the ICP only — add closed deals with record_closed_deals to sharpen it.";
1035
+ return { content: [{ type: "text", text:
1036
+ `Write the block below into ${r.target_path} with your file editor — replace any existing block ` +
1037
+ `between the nous:icp markers, or append it if there's none (create the file/section if absent). ` +
1038
+ `Leave everything outside the markers untouched. ${note}\n\n${r.block}` }] };
1039
+ }
1040
+ );
1041
+
890
1042
  // ===========================================================================
891
1043
  // TOOL: connect_integration — POST /v2/workspace/integrations
892
1044
  // The agent connects a KEY-BASED integration for the user (no clicking through
@@ -1295,6 +1447,49 @@ export function createServer() {
1295
1447
  }
1296
1448
  );
1297
1449
 
1450
+ // ===========================================================================
1451
+ // TOOL: scrape_engagers
1452
+ // On-demand LinkedIn engager scrape — mine who commented/reacted on the
1453
+ // workspace's own recent posts into the native "LinkedIn Engagers" list, NOW,
1454
+ // instead of waiting for the weekly cron. Backfill a wider window with `days`.
1455
+ // ===========================================================================
1456
+ server.tool(
1457
+ "scrape_engagers",
1458
+ "Scrape the people who commented or reacted on YOUR OWN recent LinkedIn posts into the native " +
1459
+ "\"LinkedIn Engagers\" lead list — right now, instead of waiting for the weekly auto-run. Each " +
1460
+ "engager is saved with the engagement captured (the actual comment text for comments, the " +
1461
+ "reaction for likes) on their timeline. Use when the user says \"scrape engagers\", \"who " +
1462
+ "engaged with my last post\", or \"backfill my engagers for the last N months\". `days` sets the " +
1463
+ "look-back window (default 7, since the weekly run already covers the recent past; pass a larger " +
1464
+ "value like 60 to backfill). Runs on the workspace's OWN Apify key (bring-your-own-key) — if none " +
1465
+ "is connected it says so; tell the user to add an Apify key in Integrations. The scrape runs in " +
1466
+ "the background (within a minute); the new engagers then appear in the list.",
1467
+ {
1468
+ days: z.number().int().min(1).max(120).optional().describe("Look-back window in days. Default 7. Use a larger value (e.g. 60) to backfill a gap since the last scrape."),
1469
+ },
1470
+ async ({ days }) => {
1471
+ try {
1472
+ const r = await post("/api/linkedin/engagement/scrape", { days });
1473
+ const lastLine = r.last_scraped_at
1474
+ ? `Last scraped ${relAge(r.last_scraped_at)}.`
1475
+ : "First scrape for this workspace.";
1476
+ return { content: [{ type: "text", text:
1477
+ `Engager scrape queued — mining the last ${r.days} day(s) across ${r.accounts} connected ` +
1478
+ `LinkedIn account${r.accounts === 1 ? "" : "s"}. ${lastLine} It runs in the background; ` +
1479
+ `new engagers land in the "LinkedIn Engagers" list within a minute or two.` }] };
1480
+ } catch (e) {
1481
+ const msg = /apify_not_connected/.test(e.message)
1482
+ ? "Engager scraping is bring-your-own-key. Tell the user to add their own Apify key in Integrations, then try again."
1483
+ : /linkedin_not_connected/.test(e.message)
1484
+ ? "No LinkedIn account is connected. Tell the user to connect LinkedIn in Integrations first."
1485
+ : /needs_plan/.test(e.message)
1486
+ ? "LinkedIn engager scraping is on the Pro plan and up. Tell the user to upgrade to use it."
1487
+ : `Couldn't start the scrape: ${e.message}`;
1488
+ return { content: [{ type: "text", text: msg }] };
1489
+ }
1490
+ }
1491
+ );
1492
+
1298
1493
  // ===========================================================================
1299
1494
  // TOOL: get_routing_preferences
1300
1495
  // The routing preferences that make THIS agent default to Nous for GTM. The