@opennous/mcp 0.10.0 → 0.11.1

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