@opennous/mcp 0.11.1 → 0.18.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,7 +1,13 @@
1
1
  {
2
2
  "name": "@opennous/mcp",
3
- "version": "0.11.1",
3
+ "version": "0.18.0",
4
4
  "description": "Nous MCP Server — Customer graph for GTM agents.",
5
+ "license": "AGPL-3.0-only",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/NousC/nous.git",
9
+ "directory": "apps/mcp"
10
+ },
5
11
  "type": "module",
6
12
  "bin": {
7
13
  "nous-mcp": "src/index.js"
package/src/client.js CHANGED
@@ -9,6 +9,9 @@
9
9
  */
10
10
 
11
11
  import { AsyncLocalStorage } from "node:async_hooks";
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
12
15
 
13
16
  // Resolve an env var defensively — Claude Code plugins use ${user_config.X}
14
17
  // substitution; when an optional userConfig field is left blank, the literal
@@ -32,15 +35,33 @@ export function runWithApiKey(apiKey, fn) {
32
35
  return apiKeyStore.run({ apiKey }, fn);
33
36
  }
34
37
 
38
+ // Credential written by `nous login` (the browser device-auth flow). The CLI and
39
+ // the MCP server share ~/.nous/config.json, so a user who runs the login command
40
+ // gets a key the MCP picks up on the next call — no paste, no env var.
41
+ function fileApiKey() {
42
+ try {
43
+ const dir = resolvedEnv("NOUS_CONFIG_DIR") || path.join(os.homedir(), ".nous");
44
+ const cfg = JSON.parse(fs.readFileSync(path.join(dir, "config.json"), "utf8"));
45
+ const k = cfg?.apiKey;
46
+ return k && !String(k).includes("${") ? k : undefined;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+
35
52
  function currentApiKey() {
36
- return apiKeyStore.getStore()?.apiKey ?? resolvedEnv("NOUS_API_KEY");
53
+ return apiKeyStore.getStore()?.apiKey ?? resolvedEnv("NOUS_API_KEY") ?? fileApiKey();
37
54
  }
38
55
 
39
- // stdio-only preflight: the env key must be present at startup.
56
+ // stdio-only preflight. A key may come from the env OR from `nous login`'s
57
+ // credential file. This is advisory — the server still starts without one so
58
+ // the user can run the login command after installing the plugin, and the key
59
+ // is resolved per-call.
40
60
  export function validateConfig() {
41
- if (!resolvedEnv("NOUS_API_KEY")) {
61
+ if (!resolvedEnv("NOUS_API_KEY") && !fileApiKey()) {
42
62
  throw new Error(
43
- "NOUS_API_KEY is required. Get yours at opennous.cloud Settings API Keys"
63
+ "No Nous API key found. Run the /nous-login command (or `npx @opennous/cli login`) to sign in, " +
64
+ "or set NOUS_API_KEY."
44
65
  );
45
66
  }
46
67
  }
package/src/index.js CHANGED
@@ -17,7 +17,15 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
17
17
  import { validateConfig } from "./client.js";
18
18
  import { createServer } from "./server.js";
19
19
 
20
- validateConfig();
20
+ // Advisory only — don't hard-exit if there's no key yet. The user may install
21
+ // the plugin and then run /nous-login; the server must already be running so the
22
+ // key (resolved per-call from env or ~/.nous/config.json) is picked up without a
23
+ // restart.
24
+ try {
25
+ validateConfig();
26
+ } catch (err) {
27
+ console.error(`[nous] ${err.message}`);
28
+ }
21
29
 
22
30
  const server = createServer();
23
31
  const transport = new StdioServerTransport();
package/src/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Nous MCP server factory.
3
3
  *
4
- * Builds an McpServer with the seven v2 tools registered. Both entrypoints use it:
4
+ * Builds an McpServer with the v2 tools registered. Both entrypoints use it:
5
5
  * - index.js (stdio bin, published as @opennous/mcp) — one server, env-scoped key
6
6
  * - http.js (hosted, mcp.opennous.cloud) — a fresh server per request,
7
7
  * key scoped via AsyncLocalStorage
@@ -18,13 +18,19 @@
18
18
  * attention — what needs your attention (accounts gone quiet, facts decayed)
19
19
  * verify — re-check a fact before acting on it
20
20
  * get_gtm_profile — the user's GTM profile (ICP, market, pricing, product, competitors)
21
+ * update_gtm_profile — write back a change to a GTM context section (evolve, keep history)
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_scoring_model — build/rebuild the ICP scoring model from the recorded GTM context
21
27
  */
22
28
 
23
29
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
24
30
  import { z } from "zod";
25
31
  import { get, post } from "./client.js";
26
32
 
27
- export const SERVER_VERSION = "0.11.0";
33
+ export const SERVER_VERSION = "0.18.0";
28
34
 
29
35
  // ─── helpers ──────────────────────────────────────────────────────────────────
30
36
 
@@ -50,8 +56,10 @@ export function createServer() {
50
56
  name: "nous",
51
57
  version: SERVER_VERSION,
52
58
  description:
53
- "Nous — the context layer for GTM agents. Call get_context before drafting outreach or " +
54
- "preparing for a meeting. Call record after every interaction, or whenever you learn something.",
59
+ "Nous — the context layer for GTM agents. Nous is operated by the agent, not by a human " +
60
+ "clicking around: call get_workspace_status at the start of a session to see what's set up " +
61
+ "and what to set up next. Call get_context before drafting outreach or preparing for a " +
62
+ "meeting. Call record after every interaction, or whenever you learn something.",
55
63
  icons: [
56
64
  { src: "https://opennous.cloud/newlogoP.png", mimeType: "image/png", sizes: ["64x64"] },
57
65
  ],
@@ -115,6 +123,17 @@ export function createServer() {
115
123
  }
116
124
  lines.push("");
117
125
  }
126
+ if (ctx.documents?.length) {
127
+ // Meeting briefs / notes / transcripts kept on the contact — an overview
128
+ // (snippets only). To pull relevant content, use search_notes (semantic).
129
+ lines.push("DOCUMENTS (notes & meeting records — use search_notes to search their content):");
130
+ for (const d of ctx.documents) {
131
+ const when = d.date ? ` [${relAge(d.date)}]` : "";
132
+ lines.push(` ${d.type.replace(/_/g, " ")}${d.title ? ` · ${d.title}` : ""}${when}`);
133
+ if (d.snippet) lines.push(` ${d.snippet}`);
134
+ }
135
+ lines.push("");
136
+ }
118
137
  if (ctx.stakeholders?.length) {
119
138
  lines.push("STAKEHOLDERS:");
120
139
  for (const s of ctx.stakeholders) lines.push(` ${s.name ?? "—"} — ${s.role ?? ""}`);
@@ -349,7 +368,17 @@ export function createServer() {
349
368
  const lines = [];
350
369
  for (const [cat, facts] of Object.entries(groups)) {
351
370
  lines.push(`${cat.toUpperCase()} (${facts.length}):`);
352
- for (const f of facts) lines.push(` ${f.content} [${relAge(f.recorded_at)}]`);
371
+ for (const f of facts) {
372
+ // Flag AI-drafted facts (confidence < 1) and ones not confirmed in a long
373
+ // time, so the agent treats them as provisional and prefers fresh,
374
+ // user-confirmed facts when they conflict.
375
+ const ageDays = f.recorded_at ? Math.floor((Date.now() - new Date(f.recorded_at).getTime()) / 86400000) : 0;
376
+ const tags = [];
377
+ if (typeof f.confidence === "number" && f.confidence < 1) tags.push("inferred");
378
+ if (ageDays >= 90) tags.push("stale");
379
+ const tag = tags.length ? ` (${tags.join(", ")})` : "";
380
+ lines.push(` ${f.content}${tag} [${relAge(f.recorded_at)}]`);
381
+ }
353
382
  lines.push("");
354
383
  }
355
384
  return { content: [{ type: "text", text: lines.join("\n").trim() }] };
@@ -358,5 +387,248 @@ export function createServer() {
358
387
  // Legacy alias — keeps existing integrations calling get_workspace_facts working.
359
388
  server.tool("get_workspace_facts", gtmProfileDescription, gtmProfileSchema, gtmProfileHandler);
360
389
 
390
+ // ===========================================================================
391
+ // TOOL: update_gtm_profile — POST /v2/workspace/facts
392
+ // Write-back: the agent records a durable change to the user's OWN GTM profile
393
+ // and EVOLVES the matching belief (supersede + keep history) instead of piling
394
+ // up contradictions. This is the loop that keeps the context current as the
395
+ // company learns — pair it with get_gtm_profile.
396
+ // ===========================================================================
397
+ server.tool(
398
+ "update_gtm_profile",
399
+ "Keep a SECTION of the user's OWN GTM context current. Each section is a living file: ICP, " +
400
+ "Market, Product, Pricing, Competitors, Positioning (these feed the ICP scoring model), plus " +
401
+ "'GTM Motion' (how they sell — motion, RevOps, process) and 'Notes' (a running log for anything " +
402
+ "else durable about their GTM that doesn't fit the others). Use this whenever the user states or " +
403
+ "you learn a lasting change to how THEY go to market — repriced, moved upmarket, sharpened " +
404
+ "positioning, changed their motion, won a new segment, or a useful note about how they operate. " +
405
+ "This is NOT for facts about a prospect or account (use `record` for those). " +
406
+ "Rules: keep content short and current — a sentence or two, not an essay. In the default 'replace' " +
407
+ "mode the section EVOLVES (the old version is kept as history, never silently contradicted), so " +
408
+ "just write the section's current state. Use 'append' mode to log a Notes entry without replacing. " +
409
+ "Nous is the source of truth for the GTM context — write back here instead of keeping a local file.",
410
+ {
411
+ section: z.enum(["ICP", "Market", "Product", "Pricing", "Competitors", "Positioning", "GTM Motion", "Notes"])
412
+ .describe("Which section of the GTM context this updates."),
413
+ content: z.string().describe("The section's current content — short and current, not an essay."),
414
+ mode: z.enum(["replace", "append"]).optional()
415
+ .describe("'replace' (default) evolves the section and keeps the prior version as history. 'append' logs a new entry without replacing — the default for Notes."),
416
+ supersedes: z.string().optional()
417
+ .describe("Optional id of a specific existing fact to replace (overrides section matching)."),
418
+ },
419
+ async ({ section, content, mode, supersedes }) => {
420
+ const r = await post("/v2/workspace/facts", { section, content, mode, supersedes });
421
+ const verb = r.mode === "append" ? "Logged to" : r.superseded ? "Updated" : "Recorded";
422
+ return { content: [{ type: "text", text: `${verb} ${section}: ${content}` }] };
423
+ },
424
+ );
425
+
426
+ // ===========================================================================
427
+ // TOOL: save_note — POST /v2/notes
428
+ // Attach a long-form artifact to a CONTACT: a meeting brief you wrote, a
429
+ // transcript, pre-meeting prep, or a plain note. Append-only and dated, so the
430
+ // contact builds a record across meetings. Distinct from `record` (which logs
431
+ // that an interaction happened) — this keeps the document itself.
432
+ // ===========================================================================
433
+ server.tool(
434
+ "save_note",
435
+ "Save a note or document onto a person or company so it is kept on their record — a meeting " +
436
+ "brief you wrote, a transcript, pre-meeting prep, research, or a plain note. Use this whenever " +
437
+ "you produce something durable about a specific contact that's worth keeping for next time (e.g. " +
438
+ "after writing a meeting brief, save it to the contact so future meetings can reference it). " +
439
+ "Notes are append-only and dated, so a contact builds a record across meetings — later you can " +
440
+ "read the last few and see what changed. This is NOT for logging that an interaction happened " +
441
+ "(use `record` with an interaction.* event for that), and NOT for the user's own GTM profile " +
442
+ "(use `update_gtm_profile`). Put the full text in `content` — it's kept for agents to read; the " +
443
+ "UI shows the title and date, not the whole body.",
444
+ {
445
+ focus: z.string().describe("Who to attach it to — an email, LinkedIn URL, domain, or entity UUID (not a bare name)."),
446
+ content: z.string().describe("The full note or document text (a short note or a complete brief/transcript)."),
447
+ type: z.enum(["note", "meeting_brief", "transcript", "meeting_notes", "pre_meeting", "research"])
448
+ .optional().describe("What kind of document this is (default: note)."),
449
+ title: z.string().optional().describe("A short name, e.g. 'Pre-meeting brief — renewal' or 'Transcript — Jun 1'."),
450
+ date: z.string().optional().describe("The relevant date (e.g. the meeting date, ISO or plain). Defaults to now."),
451
+ },
452
+ async ({ focus, content, type, title, date }) => {
453
+ const r = await post("/v2/notes", { focus, content, type, title, date });
454
+ const label = title || (r.doc_type || "note").replace(/_/g, " ");
455
+ return { content: [{ type: "text", text: `Saved ${label} to ${focus}.` }] };
456
+ },
457
+ );
458
+
459
+ // ===========================================================================
460
+ // TOOL: search_notes — POST /v2/notes/search
461
+ // Semantic search over saved notes & documents (briefs, transcripts, notes).
462
+ // The retrieval counterpart to save_note — pull relevant document content
463
+ // instead of dumping whole documents into context.
464
+ // ===========================================================================
465
+ server.tool(
466
+ "search_notes",
467
+ "Semantically search the saved notes & documents (meeting briefs, transcripts, meeting notes) " +
468
+ "kept on contacts. Use this to pull relevant content from the record — e.g. 'what did we discuss " +
469
+ "about pricing', 'objections raised in past meetings', or to compare across a contact's meetings. " +
470
+ "Pass `focus` to restrict to one person/company, or omit it to search across everyone. Returns the " +
471
+ "matching documents (type, title, date, similarity, snippet); get the full body with get_account.",
472
+ {
473
+ question: z.string().describe("Natural-language query to match against document content."),
474
+ focus: z.string().optional().describe("Optional — restrict to one person/company (email, LinkedIn URL, domain, or entity UUID)."),
475
+ limit: z.number().optional().describe("Max documents to return (default 8)."),
476
+ },
477
+ async ({ question, focus, limit }) => {
478
+ const r = await post("/v2/notes/search", { question, focus, limit });
479
+ if (!r.documents?.length) {
480
+ return { content: [{ type: "text", text: `No saved documents matched "${question}".` }] };
481
+ }
482
+ const lines = [`Documents matching "${question}":`, ""];
483
+ for (const d of r.documents) {
484
+ const when = d.date ? ` [${relAge(d.date)}]` : "";
485
+ lines.push(` ${d.type.replace(/_/g, " ")}${d.title ? ` · ${d.title}` : ""} (${pct(d.similarity)})${when}`);
486
+ if (d.snippet) lines.push(` ${d.snippet}`);
487
+ lines.push(` (entity_id: ${d.entity_id})`);
488
+ }
489
+ return { content: [{ type: "text", text: lines.join("\n").trim() }] };
490
+ },
491
+ );
492
+
493
+ // ===========================================================================
494
+ // TOOL: get_workspace_status — GET /v2/workspace/status
495
+ // The "one main call." Nous is operated by the agent, so the agent needs to
496
+ // know the state of the workspace: is it onboarded, is the GTM playbook built,
497
+ // which integrations are connected, is CRM sync configured, are events live —
498
+ // and what to set up next. Call this at the start of a session.
499
+ // ===========================================================================
500
+ server.tool(
501
+ "get_workspace_status",
502
+ "See the whole setup state of this workspace in one call — and what to set up next. Nous is " +
503
+ "operated by you, the agent, not by a human clicking through the app: call this at the start of " +
504
+ "a session to learn whether the workspace is onboarded, whether the GTM playbook (ICP model) is " +
505
+ "built, which integrations are connected, whether CRM sync is configured, and whether webhooks/" +
506
+ "triggers are live. Returns a ranked NEXT STEPS list — walk the user through whatever is missing " +
507
+ "(onboard them with set_workspace_profile, build the playbook with update_gtm_profile, connect " +
508
+ "their tools). Use this before offering to set anything up.",
509
+ {},
510
+ async () => {
511
+ const s = await get("/v2/workspace/status");
512
+ const setup = s.setup ?? {};
513
+ const lines = [];
514
+
515
+ const ws = s.workspace ?? {};
516
+ lines.push(`WORKSPACE: ${ws.name || "(unnamed)"}${ws.website ? ` · ${ws.website}` : ""}${ws.business_type ? ` · ${ws.business_type}` : ""}`);
517
+ lines.push("");
518
+
519
+ const mark = (b) => (b ? "✓" : "✗");
520
+ lines.push("SETUP:");
521
+ lines.push(` ${mark(setup.onboarding?.done)} Onboarding${setup.onboarding?.done ? "" : ` — missing ${(setup.onboarding?.missing ?? []).join(", ") || "details"}`}`);
522
+ lines.push(` ${mark(setup.gtm_playbook?.done)} GTM playbook${setup.gtm_playbook?.model ? " (scoring model live)" : ""}${setup.gtm_playbook?.stale_facts ? ` · ${setup.gtm_playbook.stale_facts} stale fact(s)` : ""}`);
523
+ const ints = setup.integrations?.connected ?? [];
524
+ lines.push(` ${mark((setup.integrations?.count ?? 0) > 0)} Integrations (${setup.integrations?.count ?? 0})${ints.length ? `: ${ints.map((i) => i.name).join(", ")}` : ""}`);
525
+ const crm = setup.crm_sync ?? {};
526
+ 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` : ""}`);
527
+ lines.push(` ${mark(setup.enrichment?.connected)} Enrichment${setup.enrichment?.provider ? `: ${setup.enrichment.provider}` : ""}`);
528
+ 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)`);
529
+
530
+ if (s.next_steps?.length) {
531
+ lines.push("");
532
+ lines.push("NEXT STEPS:");
533
+ for (const step of s.next_steps) {
534
+ lines.push(` • ${step.title}`);
535
+ if (step.why) lines.push(` why: ${step.why}`);
536
+ if (step.how) lines.push(` how: ${step.how}`);
537
+ }
538
+ } else {
539
+ lines.push("");
540
+ lines.push("Everything's set up. Nothing pending.");
541
+ }
542
+
543
+ return { content: [{ type: "text", text: lines.join("\n").trim() }] };
544
+ }
545
+ );
546
+
547
+ // ===========================================================================
548
+ // TOOL: set_workspace_profile — POST /v2/workspace/onboarding
549
+ // Agent-driven onboarding. Instead of a human clicking through a wizard in the
550
+ // app, you collect the basics from the user in conversation and write them
551
+ // here. This is the first thing get_workspace_status asks for when a workspace
552
+ // is new.
553
+ // ===========================================================================
554
+ server.tool(
555
+ "set_workspace_profile",
556
+ "Onboard the workspace, or update its basic profile. Nous is set up by you, the agent, in " +
557
+ "conversation — not by the user clicking through a wizard. Ask the user for their company name, " +
558
+ "their website, whether they sell a SERVICE or SOFTWARE, and a sentence describing their ideal " +
559
+ "customer, then write them here. This seeds the GTM context and the ICP scoring model. Call " +
560
+ "get_workspace_status first to see what's already set; send only the fields you're setting or " +
561
+ "changing. After this, the next step is usually the GTM playbook (update_gtm_profile).",
562
+ {
563
+ name: z.string().optional().describe("The user's company / workspace name."),
564
+ website: z.string().optional().describe("The company website (used to seed the GTM context)."),
565
+ business_type: z.enum(["service", "software"]).optional()
566
+ .describe("Whether they sell a service or software — sets the CRM's buyer terminology and default signup stage."),
567
+ plan_model: z.enum(["free_plan", "free_trial", "both", "paid_only"]).optional()
568
+ .describe("For software only: how they package (free plan, free trial, both, or paid only)."),
569
+ default_signup_stage: z.string().optional()
570
+ .describe("The pipeline stage a brand-new signup lands in (e.g. 'Lead', 'Free User'). Defaults sensibly from business_type."),
571
+ icp: z.string().optional()
572
+ .describe("A sentence or two describing their ideal customer — seeds the ICP scoring model."),
573
+ },
574
+ async ({ name, website, business_type, plan_model, default_signup_stage, icp }) => {
575
+ const r = await post("/v2/workspace/onboarding", { name, website, business_type, plan_model, default_signup_stage, icp });
576
+ const w = r.workspace ?? {};
577
+ const set = [
578
+ w.name && `name=${w.name}`,
579
+ w.website && `site=${w.website}`,
580
+ w.business_type && `type=${w.business_type}`,
581
+ icp && "ICP recorded",
582
+ ].filter(Boolean);
583
+ return { content: [{ type: "text", text:
584
+ `Workspace profile saved.${set.length ? ` ${set.join(" · ")}.` : ""}\n` +
585
+ `Next: call get_workspace_status to see what to set up next (usually the GTM playbook).` }] };
586
+ }
587
+ );
588
+
589
+ // ===========================================================================
590
+ // TOOL: build_scoring_model — POST /v2/workspace/scoring-model
591
+ // The second half of building the GTM playbook. The agent records the GTM
592
+ // context with update_gtm_profile, then calls this to turn it into a weighted
593
+ // ICP scoring model. After this, accounts get scored for fit and
594
+ // get_workspace_status shows the playbook as done.
595
+ // ===========================================================================
596
+ server.tool(
597
+ "build_scoring_model",
598
+ "Build (or rebuild) the user's ICP scoring model from the GTM context they've recorded. This is " +
599
+ "the second half of setting up the GTM playbook: first record the ICP and how they sell with " +
600
+ "update_gtm_profile, then call this to translate that context into a weighted set of scoring " +
601
+ "signals so accounts get scored for fit. If a model already exists it is left alone unless you " +
602
+ "pass force:true (use that when the GTM context has changed and the model should be rebuilt). If " +
603
+ "it reports no GTM context yet, record some with update_gtm_profile first, then call this again.",
604
+ {
605
+ force: z.boolean().optional()
606
+ .describe("Rebuild the model even if one already exists — use when the GTM context has changed."),
607
+ },
608
+ async ({ force }) => {
609
+ try {
610
+ const r = await post("/v2/workspace/scoring-model", { force: force === true });
611
+ const signals = r.signals ?? [];
612
+ const lines = [`Built the ICP scoring model — ${signals.length} signal${signals.length === 1 ? "" : "s"}:`];
613
+ for (const s of signals) lines.push(` • ${s.label ?? s.key} (weight ${s.weight})`);
614
+ lines.push("", "Accounts will now be scored for fit. Check it on the GTM Context page.");
615
+ return { content: [{ type: "text", text: lines.join("\n").trim() }] };
616
+ } catch (e) {
617
+ // Surface the actionable cases (no context yet / model already exists) as
618
+ // guidance rather than a raw error, so the agent knows what to do next.
619
+ const msg = String(e?.message ?? e);
620
+ if (msg.includes("no_gtm_context")) {
621
+ return { content: [{ type: "text", text:
622
+ "No GTM context recorded yet. Record the ICP and how they sell with update_gtm_profile first, then build the model." }] };
623
+ }
624
+ if (msg.includes("model_exists")) {
625
+ return { content: [{ type: "text", text:
626
+ "A scoring model already exists. Call build_scoring_model again with force:true to rebuild it from the current GTM context." }] };
627
+ }
628
+ throw e;
629
+ }
630
+ }
631
+ );
632
+
361
633
  return server;
362
634
  }