@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.
- package/package.json +1 -1
- package/src/server.js +197 -2
package/package.json
CHANGED
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.
|
|
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.
|
|
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
|