@opennous/mcp 0.11.1 → 0.16.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 +130 -3
package/package.json
CHANGED
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
|
|
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,16 @@
|
|
|
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
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
26
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
24
27
|
import { z } from "zod";
|
|
25
28
|
import { get, post } from "./client.js";
|
|
26
29
|
|
|
27
|
-
export const SERVER_VERSION = "0.
|
|
30
|
+
export const SERVER_VERSION = "0.16.0";
|
|
28
31
|
|
|
29
32
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
30
33
|
|
|
@@ -115,6 +118,17 @@ export function createServer() {
|
|
|
115
118
|
}
|
|
116
119
|
lines.push("");
|
|
117
120
|
}
|
|
121
|
+
if (ctx.documents?.length) {
|
|
122
|
+
// Meeting briefs / notes / transcripts kept on the contact — an overview
|
|
123
|
+
// (snippets only). To pull relevant content, use search_notes (semantic).
|
|
124
|
+
lines.push("DOCUMENTS (notes & meeting records — use search_notes to search their content):");
|
|
125
|
+
for (const d of ctx.documents) {
|
|
126
|
+
const when = d.date ? ` [${relAge(d.date)}]` : "";
|
|
127
|
+
lines.push(` ${d.type.replace(/_/g, " ")}${d.title ? ` · ${d.title}` : ""}${when}`);
|
|
128
|
+
if (d.snippet) lines.push(` ${d.snippet}`);
|
|
129
|
+
}
|
|
130
|
+
lines.push("");
|
|
131
|
+
}
|
|
118
132
|
if (ctx.stakeholders?.length) {
|
|
119
133
|
lines.push("STAKEHOLDERS:");
|
|
120
134
|
for (const s of ctx.stakeholders) lines.push(` ${s.name ?? "—"} — ${s.role ?? ""}`);
|
|
@@ -349,7 +363,17 @@ export function createServer() {
|
|
|
349
363
|
const lines = [];
|
|
350
364
|
for (const [cat, facts] of Object.entries(groups)) {
|
|
351
365
|
lines.push(`${cat.toUpperCase()} (${facts.length}):`);
|
|
352
|
-
for (const f of facts)
|
|
366
|
+
for (const f of facts) {
|
|
367
|
+
// Flag AI-drafted facts (confidence < 1) and ones not confirmed in a long
|
|
368
|
+
// time, so the agent treats them as provisional and prefers fresh,
|
|
369
|
+
// user-confirmed facts when they conflict.
|
|
370
|
+
const ageDays = f.recorded_at ? Math.floor((Date.now() - new Date(f.recorded_at).getTime()) / 86400000) : 0;
|
|
371
|
+
const tags = [];
|
|
372
|
+
if (typeof f.confidence === "number" && f.confidence < 1) tags.push("inferred");
|
|
373
|
+
if (ageDays >= 90) tags.push("stale");
|
|
374
|
+
const tag = tags.length ? ` (${tags.join(", ")})` : "";
|
|
375
|
+
lines.push(` ${f.content}${tag} [${relAge(f.recorded_at)}]`);
|
|
376
|
+
}
|
|
353
377
|
lines.push("");
|
|
354
378
|
}
|
|
355
379
|
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
@@ -358,5 +382,108 @@ export function createServer() {
|
|
|
358
382
|
// Legacy alias — keeps existing integrations calling get_workspace_facts working.
|
|
359
383
|
server.tool("get_workspace_facts", gtmProfileDescription, gtmProfileSchema, gtmProfileHandler);
|
|
360
384
|
|
|
385
|
+
// ===========================================================================
|
|
386
|
+
// TOOL: update_gtm_profile — POST /v2/workspace/facts
|
|
387
|
+
// Write-back: the agent records a durable change to the user's OWN GTM profile
|
|
388
|
+
// and EVOLVES the matching belief (supersede + keep history) instead of piling
|
|
389
|
+
// up contradictions. This is the loop that keeps the context current as the
|
|
390
|
+
// company learns — pair it with get_gtm_profile.
|
|
391
|
+
// ===========================================================================
|
|
392
|
+
server.tool(
|
|
393
|
+
"update_gtm_profile",
|
|
394
|
+
"Keep a SECTION of the user's OWN GTM context current. Each section is a living file: ICP, " +
|
|
395
|
+
"Market, Product, Pricing, Competitors, Positioning (these feed the ICP scoring model), plus " +
|
|
396
|
+
"'GTM Motion' (how they sell — motion, RevOps, process) and 'Notes' (a running log for anything " +
|
|
397
|
+
"else durable about their GTM that doesn't fit the others). Use this whenever the user states or " +
|
|
398
|
+
"you learn a lasting change to how THEY go to market — repriced, moved upmarket, sharpened " +
|
|
399
|
+
"positioning, changed their motion, won a new segment, or a useful note about how they operate. " +
|
|
400
|
+
"This is NOT for facts about a prospect or account (use `record` for those). " +
|
|
401
|
+
"Rules: keep content short and current — a sentence or two, not an essay. In the default 'replace' " +
|
|
402
|
+
"mode the section EVOLVES (the old version is kept as history, never silently contradicted), so " +
|
|
403
|
+
"just write the section's current state. Use 'append' mode to log a Notes entry without replacing. " +
|
|
404
|
+
"Nous is the source of truth for the GTM context — write back here instead of keeping a local file.",
|
|
405
|
+
{
|
|
406
|
+
section: z.enum(["ICP", "Market", "Product", "Pricing", "Competitors", "Positioning", "GTM Motion", "Notes"])
|
|
407
|
+
.describe("Which section of the GTM context this updates."),
|
|
408
|
+
content: z.string().describe("The section's current content — short and current, not an essay."),
|
|
409
|
+
mode: z.enum(["replace", "append"]).optional()
|
|
410
|
+
.describe("'replace' (default) evolves the section and keeps the prior version as history. 'append' logs a new entry without replacing — the default for Notes."),
|
|
411
|
+
supersedes: z.string().optional()
|
|
412
|
+
.describe("Optional id of a specific existing fact to replace (overrides section matching)."),
|
|
413
|
+
},
|
|
414
|
+
async ({ section, content, mode, supersedes }) => {
|
|
415
|
+
const r = await post("/v2/workspace/facts", { section, content, mode, supersedes });
|
|
416
|
+
const verb = r.mode === "append" ? "Logged to" : r.superseded ? "Updated" : "Recorded";
|
|
417
|
+
return { content: [{ type: "text", text: `${verb} ${section}: ${content}` }] };
|
|
418
|
+
},
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
// ===========================================================================
|
|
422
|
+
// TOOL: save_note — POST /v2/notes
|
|
423
|
+
// Attach a long-form artifact to a CONTACT: a meeting brief you wrote, a
|
|
424
|
+
// transcript, pre-meeting prep, or a plain note. Append-only and dated, so the
|
|
425
|
+
// contact builds a record across meetings. Distinct from `record` (which logs
|
|
426
|
+
// that an interaction happened) — this keeps the document itself.
|
|
427
|
+
// ===========================================================================
|
|
428
|
+
server.tool(
|
|
429
|
+
"save_note",
|
|
430
|
+
"Save a note or document onto a person or company so it is kept on their record — a meeting " +
|
|
431
|
+
"brief you wrote, a transcript, pre-meeting prep, research, or a plain note. Use this whenever " +
|
|
432
|
+
"you produce something durable about a specific contact that's worth keeping for next time (e.g. " +
|
|
433
|
+
"after writing a meeting brief, save it to the contact so future meetings can reference it). " +
|
|
434
|
+
"Notes are append-only and dated, so a contact builds a record across meetings — later you can " +
|
|
435
|
+
"read the last few and see what changed. This is NOT for logging that an interaction happened " +
|
|
436
|
+
"(use `record` with an interaction.* event for that), and NOT for the user's own GTM profile " +
|
|
437
|
+
"(use `update_gtm_profile`). Put the full text in `content` — it's kept for agents to read; the " +
|
|
438
|
+
"UI shows the title and date, not the whole body.",
|
|
439
|
+
{
|
|
440
|
+
focus: z.string().describe("Who to attach it to — an email, LinkedIn URL, domain, or entity UUID (not a bare name)."),
|
|
441
|
+
content: z.string().describe("The full note or document text (a short note or a complete brief/transcript)."),
|
|
442
|
+
type: z.enum(["note", "meeting_brief", "transcript", "meeting_notes", "pre_meeting", "research"])
|
|
443
|
+
.optional().describe("What kind of document this is (default: note)."),
|
|
444
|
+
title: z.string().optional().describe("A short name, e.g. 'Pre-meeting brief — renewal' or 'Transcript — Jun 1'."),
|
|
445
|
+
date: z.string().optional().describe("The relevant date (e.g. the meeting date, ISO or plain). Defaults to now."),
|
|
446
|
+
},
|
|
447
|
+
async ({ focus, content, type, title, date }) => {
|
|
448
|
+
const r = await post("/v2/notes", { focus, content, type, title, date });
|
|
449
|
+
const label = title || (r.doc_type || "note").replace(/_/g, " ");
|
|
450
|
+
return { content: [{ type: "text", text: `Saved ${label} to ${focus}.` }] };
|
|
451
|
+
},
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
// ===========================================================================
|
|
455
|
+
// TOOL: search_notes — POST /v2/notes/search
|
|
456
|
+
// Semantic search over saved notes & documents (briefs, transcripts, notes).
|
|
457
|
+
// The retrieval counterpart to save_note — pull relevant document content
|
|
458
|
+
// instead of dumping whole documents into context.
|
|
459
|
+
// ===========================================================================
|
|
460
|
+
server.tool(
|
|
461
|
+
"search_notes",
|
|
462
|
+
"Semantically search the saved notes & documents (meeting briefs, transcripts, meeting notes) " +
|
|
463
|
+
"kept on contacts. Use this to pull relevant content from the record — e.g. 'what did we discuss " +
|
|
464
|
+
"about pricing', 'objections raised in past meetings', or to compare across a contact's meetings. " +
|
|
465
|
+
"Pass `focus` to restrict to one person/company, or omit it to search across everyone. Returns the " +
|
|
466
|
+
"matching documents (type, title, date, similarity, snippet); get the full body with get_account.",
|
|
467
|
+
{
|
|
468
|
+
question: z.string().describe("Natural-language query to match against document content."),
|
|
469
|
+
focus: z.string().optional().describe("Optional — restrict to one person/company (email, LinkedIn URL, domain, or entity UUID)."),
|
|
470
|
+
limit: z.number().optional().describe("Max documents to return (default 8)."),
|
|
471
|
+
},
|
|
472
|
+
async ({ question, focus, limit }) => {
|
|
473
|
+
const r = await post("/v2/notes/search", { question, focus, limit });
|
|
474
|
+
if (!r.documents?.length) {
|
|
475
|
+
return { content: [{ type: "text", text: `No saved documents matched "${question}".` }] };
|
|
476
|
+
}
|
|
477
|
+
const lines = [`Documents matching "${question}":`, ""];
|
|
478
|
+
for (const d of r.documents) {
|
|
479
|
+
const when = d.date ? ` [${relAge(d.date)}]` : "";
|
|
480
|
+
lines.push(` ${d.type.replace(/_/g, " ")}${d.title ? ` · ${d.title}` : ""} (${pct(d.similarity)})${when}`);
|
|
481
|
+
if (d.snippet) lines.push(` ${d.snippet}`);
|
|
482
|
+
lines.push(` (entity_id: ${d.entity_id})`);
|
|
483
|
+
}
|
|
484
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
485
|
+
},
|
|
486
|
+
);
|
|
487
|
+
|
|
361
488
|
return server;
|
|
362
489
|
}
|