@opennous/mcp 0.10.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 +11 -18
- package/src/client.js +28 -3
- package/src/http.js +122 -0
- package/src/index.js +7 -333
- package/src/server.js +489 -0
- package/LICENSE +0 -661
package/src/server.js
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nous MCP server factory.
|
|
3
|
+
*
|
|
4
|
+
* Builds an McpServer with the v2 tools registered. Both entrypoints use it:
|
|
5
|
+
* - index.js (stdio bin, published as @opennous/mcp) — one server, env-scoped key
|
|
6
|
+
* - http.js (hosted, mcp.opennous.cloud) — a fresh server per request,
|
|
7
|
+
* key scoped via AsyncLocalStorage
|
|
8
|
+
*
|
|
9
|
+
* The tools are thin clients of the Context API (see client.js). The agent never
|
|
10
|
+
* sees raw rows — it gets engineered, epistemics-tagged context. It never
|
|
11
|
+
* "updates" — it records observations; Nous derives.
|
|
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
|
+
* record — record what happened / what you learned (observe, never update)
|
|
17
|
+
* query — retrieve + summarise a corpus of activity across many people
|
|
18
|
+
* attention — what needs your attention (accounts gone quiet, facts decayed)
|
|
19
|
+
* verify — re-check a fact before acting on it
|
|
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
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
27
|
+
import { z } from "zod";
|
|
28
|
+
import { get, post } from "./client.js";
|
|
29
|
+
|
|
30
|
+
export const SERVER_VERSION = "0.16.0";
|
|
31
|
+
|
|
32
|
+
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function relAge(ts) {
|
|
35
|
+
if (!ts) return "—";
|
|
36
|
+
const d = Math.floor((Date.now() - new Date(ts).getTime()) / 86400000);
|
|
37
|
+
if (d < 1) return "today";
|
|
38
|
+
if (d === 1) return "1d ago";
|
|
39
|
+
if (d < 30) return `${d}d ago`;
|
|
40
|
+
const m = Math.floor(d / 30);
|
|
41
|
+
if (m < 12) return `${m}mo ago`;
|
|
42
|
+
return `${Math.floor(m / 12)}y ago`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const fmtType = (p) => (p || "").replace(/^interaction\./, "").replace(/_/g, " ");
|
|
46
|
+
const fmtVal = (v) => (v != null && typeof v === "object") ? JSON.stringify(v) : String(v ?? "");
|
|
47
|
+
const pct = (c) => `${Math.round((c ?? 0) * 100)}%`;
|
|
48
|
+
|
|
49
|
+
// ─── factory ──────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export function createServer() {
|
|
52
|
+
const server = new McpServer({
|
|
53
|
+
name: "nous",
|
|
54
|
+
version: SERVER_VERSION,
|
|
55
|
+
description:
|
|
56
|
+
"Nous — the context layer for GTM agents. Call get_context before drafting outreach or " +
|
|
57
|
+
"preparing for a meeting. Call record after every interaction, or whenever you learn something.",
|
|
58
|
+
icons: [
|
|
59
|
+
{ src: "https://opennous.cloud/newlogoP.png", mimeType: "image/png", sizes: ["64x64"] },
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ===========================================================================
|
|
64
|
+
// TOOL: get_context — POST /v2/context
|
|
65
|
+
// The headline tool. Engineered, intent-shaped context for a specific task.
|
|
66
|
+
// ===========================================================================
|
|
67
|
+
server.tool(
|
|
68
|
+
"get_context",
|
|
69
|
+
"Get engineered context for a specific task about a person or company. Pass their email (or " +
|
|
70
|
+
"entity id) and the intent. Returns a focused, ranked context block: the facts that matter for " +
|
|
71
|
+
"that task — each with a confidence and a freshness — plus the recent timeline, the buying-group " +
|
|
72
|
+
"stakeholders, open predictions, and the account's ICP fit score (0-100 + why). Call this before " +
|
|
73
|
+
"drafting outreach, preparing for a meeting, " +
|
|
74
|
+
"or making any decision about a person. A fact's freshness tells you whether to trust it: 'fresh' " +
|
|
75
|
+
"act on it, 'suspect'/'expired' verify first.",
|
|
76
|
+
{
|
|
77
|
+
focus: z.string().describe("Who to look up — an email, a LinkedIn URL, a domain, an entity UUID, or a name. A name may match several people; you'll get candidates to choose from."),
|
|
78
|
+
intent: z.enum(["draft_email", "follow_up", "meeting_prep", "call_prep", "account_review"])
|
|
79
|
+
.optional()
|
|
80
|
+
.describe("What you are about to do — shapes which context surfaces (default: account_review)"),
|
|
81
|
+
budget_tokens: z.number().optional().describe("Approximate token budget for the context block"),
|
|
82
|
+
},
|
|
83
|
+
async ({ focus, intent, budget_tokens }) => {
|
|
84
|
+
const ctx = await post("/v2/context", { focus, intent: intent ?? "account_review", budget_tokens });
|
|
85
|
+
|
|
86
|
+
// a name matched several people — surface the candidates to choose from
|
|
87
|
+
if (ctx.status === "ambiguous") {
|
|
88
|
+
const opts = (ctx.candidates ?? []).map(c =>
|
|
89
|
+
` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
|
|
90
|
+
return { content: [{ type: "text", text:
|
|
91
|
+
`"${focus}" matches several people. Call get_context again with one of these entity ids:\n${opts}` }] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const lines = [ctx.summary, ""];
|
|
95
|
+
|
|
96
|
+
if (ctx.icp) {
|
|
97
|
+
const label = ctx.icp.score >= 70 ? "strong fit" : ctx.icp.score >= 40 ? "moderate fit" : "weak fit";
|
|
98
|
+
lines.push(`ICP FIT: ${ctx.icp.score}/100 — ${label}${ctx.icp.reason ? ` (${ctx.icp.reason})` : ""}`);
|
|
99
|
+
lines.push("");
|
|
100
|
+
}
|
|
101
|
+
if (ctx.claims?.length) {
|
|
102
|
+
lines.push(`FACTS (${ctx.meta?.claims_returned ?? ctx.claims.length}):`);
|
|
103
|
+
for (const c of ctx.claims) {
|
|
104
|
+
lines.push(` ${c.property}: ${fmtVal(c.value)} [${pct(c.confidence)} · ${c.freshness}]`);
|
|
105
|
+
}
|
|
106
|
+
lines.push("");
|
|
107
|
+
}
|
|
108
|
+
if (ctx.workspace?.length) {
|
|
109
|
+
lines.push("YOUR CONTEXT (ICP / product / positioning):");
|
|
110
|
+
for (const w of ctx.workspace) lines.push(` ${w.property}: ${fmtVal(w.value)}`);
|
|
111
|
+
lines.push("");
|
|
112
|
+
}
|
|
113
|
+
if (ctx.timeline?.length) {
|
|
114
|
+
lines.push("TIMELINE:");
|
|
115
|
+
for (const t of ctx.timeline) {
|
|
116
|
+
if (t.tier === "count") lines.push(` ${t.count}× ${fmtType(t.type)}`);
|
|
117
|
+
else lines.push(` ${relAge(t.when)} ${fmtType(t.type)}${t.summary ? `: ${t.summary}` : ""}`);
|
|
118
|
+
}
|
|
119
|
+
lines.push("");
|
|
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
|
+
}
|
|
132
|
+
if (ctx.stakeholders?.length) {
|
|
133
|
+
lines.push("STAKEHOLDERS:");
|
|
134
|
+
for (const s of ctx.stakeholders) lines.push(` ${s.name ?? "—"} — ${s.role ?? ""}`);
|
|
135
|
+
lines.push("");
|
|
136
|
+
}
|
|
137
|
+
if (ctx.predictions?.length) {
|
|
138
|
+
lines.push("PREDICTIONS:");
|
|
139
|
+
for (const p of ctx.predictions) {
|
|
140
|
+
lines.push(` ${p.kind}: ${fmtVal(p.value)} (${pct(p.confidence)})`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
content: [{ type: "text", text: `${lines.join("\n").trim()}\n\n(entity_id: ${ctx.entity?.id})` }],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// ===========================================================================
|
|
150
|
+
// TOOL: get_account — GET /v2/accounts/:id
|
|
151
|
+
// The full account-record projection. For a focused view, prefer get_context.
|
|
152
|
+
// ===========================================================================
|
|
153
|
+
server.tool(
|
|
154
|
+
"get_account",
|
|
155
|
+
"Get the full account record for a person or company — every known fact (claim) with its " +
|
|
156
|
+
"confidence and freshness, plus the recent activity timeline. Pass an email or entity UUID. " +
|
|
157
|
+
"For a task-specific, ranked view, prefer get_context.",
|
|
158
|
+
{ id: z.string().describe("Email address or entity UUID") },
|
|
159
|
+
async ({ id }) => {
|
|
160
|
+
const rec = await get(`/v2/accounts/${encodeURIComponent(id)}`);
|
|
161
|
+
const lines = [`${rec.type} · ${rec.entity_id}`, ""];
|
|
162
|
+
|
|
163
|
+
if (rec.icp) {
|
|
164
|
+
const label = rec.icp.score >= 70 ? "strong fit" : rec.icp.score >= 40 ? "moderate fit" : "weak fit";
|
|
165
|
+
lines.push(`ICP FIT: ${rec.icp.score}/100 — ${label}${rec.icp.reason ? ` (${rec.icp.reason})` : ""}`);
|
|
166
|
+
lines.push("");
|
|
167
|
+
}
|
|
168
|
+
const claims = Object.values(rec.claims ?? {});
|
|
169
|
+
if (claims.length) {
|
|
170
|
+
lines.push(`FACTS (${claims.length}):`);
|
|
171
|
+
for (const c of claims) {
|
|
172
|
+
lines.push(` ${c.property}: ${fmtVal(c.value)} [${pct(c.confidence)} · ${c.freshness}]`);
|
|
173
|
+
}
|
|
174
|
+
lines.push("");
|
|
175
|
+
}
|
|
176
|
+
const obs = rec.recent_observations ?? [];
|
|
177
|
+
if (obs.length) {
|
|
178
|
+
lines.push(`TIMELINE (${obs.length}):`);
|
|
179
|
+
for (const o of obs.slice(0, 30)) {
|
|
180
|
+
lines.push(` ${relAge(o.observed_at)} ${fmtType(o.property)}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// ===========================================================================
|
|
188
|
+
// TOOL: record — POST /v2/observations
|
|
189
|
+
// The single write verb. You observe — Nous derives the updated facts.
|
|
190
|
+
// ===========================================================================
|
|
191
|
+
server.tool(
|
|
192
|
+
"record",
|
|
193
|
+
"Record what happened or what you learned about a person or company. You never overwrite " +
|
|
194
|
+
"anything — you observe, and Nous derives the updated facts. Use kind:'event' for an interaction " +
|
|
195
|
+
"(property like 'interaction.email_sent', 'interaction.call_held', 'interaction.email_reply') and " +
|
|
196
|
+
"kind:'state' for a fact (property like 'job_title', 'deal.proposal_amount'). Examples — sent an " +
|
|
197
|
+
"email: {kind:'event',property:'interaction.email_sent',value:{description:'intro email'}}; " +
|
|
198
|
+
"learned their title changed: {kind:'state',property:'job_title',value:'VP of Engineering'}; " +
|
|
199
|
+
"a fact ended (they left): {kind:'state',property:'job_title',value:null}.",
|
|
200
|
+
{
|
|
201
|
+
focus: z.string().describe("Email address or entity UUID of the person or company"),
|
|
202
|
+
observations: z.array(z.object({
|
|
203
|
+
kind: z.enum(["event", "state"]).describe("event = an interaction; state = a fact"),
|
|
204
|
+
property: z.string().describe("e.g. 'interaction.email_sent' or 'job_title'"),
|
|
205
|
+
value: z.any().optional().describe("the event detail or the fact value; null = the fact ended"),
|
|
206
|
+
source: z.string().optional().describe("where this came from (default: agent)"),
|
|
207
|
+
})).describe("One or more observations to record"),
|
|
208
|
+
},
|
|
209
|
+
async ({ focus, observations }) => {
|
|
210
|
+
const result = await post("/v2/observations", { focus, observations });
|
|
211
|
+
const parts = [`Recorded ${result.recorded} observation${result.recorded !== 1 ? "s" : ""}.`];
|
|
212
|
+
if (result.claims_recomputed?.length) {
|
|
213
|
+
parts.push(`Facts updated: ${result.claims_recomputed.join(", ")}.`);
|
|
214
|
+
}
|
|
215
|
+
parts.push(`(entity_id: ${result.entity_id})`);
|
|
216
|
+
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
217
|
+
}
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// ===========================================================================
|
|
221
|
+
// TOOL: query — POST /v2/query
|
|
222
|
+
// Retrieve a corpus of activity across many people. You do the analysis.
|
|
223
|
+
// ===========================================================================
|
|
224
|
+
server.tool(
|
|
225
|
+
"query",
|
|
226
|
+
"Retrieve and summarise activity across many people. Three powers:\n" +
|
|
227
|
+
" 1. return:'entities' groups results by person/company (one row per entity, ranked by " +
|
|
228
|
+
"most-recent matching activity). Use for 'hottest leads', 'who replied this week', " +
|
|
229
|
+
"'who's in evaluating stage'.\n" +
|
|
230
|
+
" 2. `without` subtracts entities — 'sent in 5d MINUS replied in 5d' = 'no-reply leads'. " +
|
|
231
|
+
"'activity in 30d MINUS activity in 5d' = 'cooled leads'.\n" +
|
|
232
|
+
" 3. rollups.by_value appears when scope.kind='state' — counts entities by current value " +
|
|
233
|
+
"(use scope.property='stage' for funnel reports).",
|
|
234
|
+
{
|
|
235
|
+
scope: z.object({
|
|
236
|
+
kind: z.enum(["event", "state"]).optional(),
|
|
237
|
+
property: z.string().optional().describe("property prefix — 'interaction.email' covers email_sent and email_replied"),
|
|
238
|
+
source: z.string().optional().describe("e.g. 'gmail', 'linkedin', 'slack'"),
|
|
239
|
+
entity_id: z.string().optional().describe("scope to one person/company"),
|
|
240
|
+
since_days: z.number().optional().describe("only activity within the last N days"),
|
|
241
|
+
limit: z.number().optional().describe("max items (default 50, cap 200)"),
|
|
242
|
+
}).describe("Corpus filter"),
|
|
243
|
+
without: z.object({
|
|
244
|
+
kind: z.enum(["event", "state"]).optional(),
|
|
245
|
+
property: z.string().optional(),
|
|
246
|
+
source: z.string().optional(),
|
|
247
|
+
entity_id: z.string().optional(),
|
|
248
|
+
since_days: z.number().optional(),
|
|
249
|
+
}).optional().describe("Subtract entities matching this scope from the result — same shape as scope. Enables 'sent but no reply', 'cooled in last N days'."),
|
|
250
|
+
return: z.enum(["observations", "entities"]).optional()
|
|
251
|
+
.describe("observations (default) = one row per observation. entities = one row per entity, ranked by most-recent matching activity."),
|
|
252
|
+
question: z.string().optional().describe("What you want to learn — echoed back; enables semantic ranking"),
|
|
253
|
+
},
|
|
254
|
+
async ({ scope, without, return: returnMode, question }) => {
|
|
255
|
+
const body = { scope, question };
|
|
256
|
+
if (without) body.without = without;
|
|
257
|
+
if (returnMode) body.return = returnMode;
|
|
258
|
+
const r = await post("/v2/query", body);
|
|
259
|
+
const head = `${r.matched} match${r.matched !== 1 ? "es" : ""}` +
|
|
260
|
+
(r.sampled ? ` (showing ${r.returned})` : "") +
|
|
261
|
+
(r.return === "entities" ? " · grouped by entity" : "");
|
|
262
|
+
const roll = Object.entries(r.rollups?.by_type ?? {})
|
|
263
|
+
.map(([t, n]) => `${n}× ${fmtType(t)}`).join(" · ");
|
|
264
|
+
const lines = [head, roll].filter(Boolean);
|
|
265
|
+
if (r.rollups?.by_value && Object.keys(r.rollups.by_value).length) {
|
|
266
|
+
lines.push("BY VALUE: " + Object.entries(r.rollups.by_value).map(([v, n]) => `${v}: ${n}`).join(", "));
|
|
267
|
+
}
|
|
268
|
+
lines.push("");
|
|
269
|
+
for (const it of r.items ?? []) {
|
|
270
|
+
if (r.return === "entities") {
|
|
271
|
+
lines.push(` ${it.entity_name ?? it.entity_id} ` +
|
|
272
|
+
`(${it.matches} match${it.matches !== 1 ? "es" : ""}, last ${relAge(it.most_recent_at)})` +
|
|
273
|
+
(it.most_recent_value != null ? ` → ${fmtVal(it.most_recent_value)}` : "") +
|
|
274
|
+
(it.most_recent_summary ? `\n ${it.most_recent_summary}` : ""));
|
|
275
|
+
} else {
|
|
276
|
+
lines.push(` ${relAge(it.when)} ${it.entity_name ?? it.entity_id} ` +
|
|
277
|
+
`${fmtType(it.type)}${it.summary ? `: ${it.summary}` : ""}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
281
|
+
}
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
// ===========================================================================
|
|
285
|
+
// TOOL: attention — GET /v2/attention
|
|
286
|
+
// What to look at: accounts gone quiet, key facts decayed.
|
|
287
|
+
// ===========================================================================
|
|
288
|
+
server.tool(
|
|
289
|
+
"attention",
|
|
290
|
+
"What needs your attention across the workspace right now — accounts that have gone quiet and " +
|
|
291
|
+
"key facts that have decayed. Returns ranked items, each with what happened and a suggested " +
|
|
292
|
+
"action. Call this to decide who to work next.",
|
|
293
|
+
{
|
|
294
|
+
limit: z.number().min(1).max(100).optional().describe("Max items (default 25)"),
|
|
295
|
+
},
|
|
296
|
+
async ({ limit }) => {
|
|
297
|
+
const r = await get("/v2/attention", limit ? { limit } : {});
|
|
298
|
+
if (!r.items?.length) {
|
|
299
|
+
return { content: [{ type: "text", text: "Nothing needs attention right now." }] };
|
|
300
|
+
}
|
|
301
|
+
const lines = r.items.map(it =>
|
|
302
|
+
` ${it.entity_name ?? it.entity_id} — ${it.what}\n → ${it.suggested_action}`);
|
|
303
|
+
return { content: [{ type: "text", text: `Needs attention (${r.items.length}):\n${lines.join("\n")}` }] };
|
|
304
|
+
}
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
// ===========================================================================
|
|
308
|
+
// TOOL: verify — POST /v2/verify
|
|
309
|
+
// Re-check a fact before acting on it — the calibration check.
|
|
310
|
+
// ===========================================================================
|
|
311
|
+
server.tool(
|
|
312
|
+
"verify",
|
|
313
|
+
"Re-check a specific fact before you act on it — e.g. an email or a deal stage that looks stale " +
|
|
314
|
+
"in get_context. Pass the person/company and the property name. Returns the fact re-derived from " +
|
|
315
|
+
"current evidence, and tells you whether it is still unverified.",
|
|
316
|
+
{
|
|
317
|
+
focus: z.string().describe("Email, LinkedIn URL, entity UUID, or name"),
|
|
318
|
+
property: z.string().describe("The fact to re-check — e.g. 'email', 'job_title', 'pipeline_stage'"),
|
|
319
|
+
},
|
|
320
|
+
async ({ focus, property }) => {
|
|
321
|
+
const r = await post("/v2/verify", { focus, property });
|
|
322
|
+
if (r.status === "ambiguous") {
|
|
323
|
+
const opts = (r.candidates ?? []).map(c =>
|
|
324
|
+
` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
|
|
325
|
+
return { content: [{ type: "text", text:
|
|
326
|
+
`"${focus}" matches several people. Call verify again with one of these entity ids:\n${opts}` }] };
|
|
327
|
+
}
|
|
328
|
+
const a = r.after ?? {};
|
|
329
|
+
return { content: [{ type: "text", text:
|
|
330
|
+
`${property}: ${fmtVal(a.value)} [${pct(a.confidence)} · ${a.freshness}]\n${r.note ?? ""}` }] };
|
|
331
|
+
}
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
// ===========================================================================
|
|
335
|
+
// TOOL: get_gtm_profile — GET /v2/workspace/facts
|
|
336
|
+
// The user's OWN GTM profile: ICP, market, product, pricing, competitors.
|
|
337
|
+
// Use this for any question about the user's business — NOT get_account.
|
|
338
|
+
// Registered also under the legacy name get_workspace_facts for back-compat.
|
|
339
|
+
// ===========================================================================
|
|
340
|
+
const gtmProfileDescription =
|
|
341
|
+
"Get the user's OWN GTM profile — their ICP, target market, product, pricing, " +
|
|
342
|
+
"competitors, and positioning. These are NOT facts about a person or company; they are " +
|
|
343
|
+
"the user's own business profile. Use this for any question about the user's ICP, target " +
|
|
344
|
+
"buyer, pricing, market, or differentiators. ALWAYS prefer this over query/get_account " +
|
|
345
|
+
"when the question is about the user's business.";
|
|
346
|
+
const gtmProfileSchema = {
|
|
347
|
+
categories: z.array(z.string()).optional()
|
|
348
|
+
.describe("Optional category filter, e.g. ['ICP'] or ['Pricing','Competitors']. Omit for all."),
|
|
349
|
+
limit: z.number().min(1).max(500).optional()
|
|
350
|
+
.describe("Max facts to return (default 50)"),
|
|
351
|
+
};
|
|
352
|
+
const gtmProfileHandler = async ({ categories, limit }) => {
|
|
353
|
+
const params = {};
|
|
354
|
+
if (categories?.length) params.categories = categories.join(",");
|
|
355
|
+
if (limit != null) params.limit = limit;
|
|
356
|
+
const r = await get("/v2/workspace/facts", params);
|
|
357
|
+
if (!r.facts?.length) {
|
|
358
|
+
return { content: [{ type: "text", text:
|
|
359
|
+
"No GTM profile recorded yet. The user can set it up in the GTM Context tab." }] };
|
|
360
|
+
}
|
|
361
|
+
const groups = {};
|
|
362
|
+
for (const f of r.facts) (groups[f.category] ??= []).push(f);
|
|
363
|
+
const lines = [];
|
|
364
|
+
for (const [cat, facts] of Object.entries(groups)) {
|
|
365
|
+
lines.push(`${cat.toUpperCase()} (${facts.length}):`);
|
|
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
|
+
}
|
|
377
|
+
lines.push("");
|
|
378
|
+
}
|
|
379
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
380
|
+
};
|
|
381
|
+
server.tool("get_gtm_profile", gtmProfileDescription, gtmProfileSchema, gtmProfileHandler);
|
|
382
|
+
// Legacy alias — keeps existing integrations calling get_workspace_facts working.
|
|
383
|
+
server.tool("get_workspace_facts", gtmProfileDescription, gtmProfileSchema, gtmProfileHandler);
|
|
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
|
+
|
|
488
|
+
return server;
|
|
489
|
+
}
|