@opennous/mcp 0.8.9
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 +21 -0
- package/src/client.js +73 -0
- package/src/index.js +670 -0
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opennous/mcp",
|
|
3
|
+
"version": "0.8.9",
|
|
4
|
+
"description": "Nous MCP Server — GTM data infrastructure for agents.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"nous-mcp": "src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/index.js",
|
|
10
|
+
"files": ["src"],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"dev": "node --watch src/index.js",
|
|
13
|
+
"start": "node src/index.js",
|
|
14
|
+
"typecheck": "echo 'no ts in mcp — skipping'"
|
|
15
|
+
},
|
|
16
|
+
"keywords": ["mcp", "nous", "memory", "crm", "gtm", "agents"],
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
19
|
+
"zod": "^3.23.8"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nous API Client
|
|
3
|
+
* Thin HTTP wrapper that handles auth, workspace context, and error handling.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const API_URL = process.env.NOUS_API_URL || "https://api.opennous.cloud";
|
|
7
|
+
const API_KEY = process.env.NOUS_API_KEY;
|
|
8
|
+
|
|
9
|
+
export function validateConfig() {
|
|
10
|
+
if (!API_KEY) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
"NOUS_API_KEY is required. Get yours at opennous.cloud → Settings → API Keys"
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function request(method, path, { body, query } = {}) {
|
|
18
|
+
const url = new URL(path, API_URL);
|
|
19
|
+
|
|
20
|
+
if (query) {
|
|
21
|
+
for (const [key, value] of Object.entries(query)) {
|
|
22
|
+
if (value !== undefined && value !== null) {
|
|
23
|
+
url.searchParams.set(key, String(value));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const headers = {
|
|
29
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
"X-Nous-Client": "mcp",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const res = await fetch(url.toString(), {
|
|
35
|
+
method,
|
|
36
|
+
headers,
|
|
37
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
let errorMessage;
|
|
42
|
+
try {
|
|
43
|
+
const err = await res.json();
|
|
44
|
+
errorMessage = err.error || err.message || res.statusText;
|
|
45
|
+
} catch {
|
|
46
|
+
errorMessage = res.statusText;
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`Nous API error (${res.status}): ${errorMessage}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Some endpoints return empty 204
|
|
52
|
+
if (res.status === 204) return { success: true };
|
|
53
|
+
|
|
54
|
+
return res.json();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function get(path, query) {
|
|
58
|
+
return request("GET", path, { query });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function post(path, body) {
|
|
62
|
+
return request("POST", path, { body });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function patch(path, body) {
|
|
66
|
+
return request("PATCH", path, { body });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function del(path) {
|
|
70
|
+
return request("DELETE", path);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export { API_URL };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Nous MCP Server — contact intelligence for GTM agents.
|
|
5
|
+
*
|
|
6
|
+
* Required env vars:
|
|
7
|
+
* NOUS_API_KEY — Your Nous API key (Settings → API Keys)
|
|
8
|
+
*
|
|
9
|
+
* Optional:
|
|
10
|
+
* NOUS_API_URL — API base URL (default: https://api.opennous.cloud)
|
|
11
|
+
*
|
|
12
|
+
* Tools:
|
|
13
|
+
* get_contact — full contact profile: identity + activities + facts + summary
|
|
14
|
+
* get_company — full company profile: org details + all contacts + company facts
|
|
15
|
+
* create_contact — add a new contact with full profile fields
|
|
16
|
+
* update_contact — update profile fields on an existing contact
|
|
17
|
+
* track — record that something happened (call, email, meeting, visit)
|
|
18
|
+
* remember — store a fact learned about a contact or company
|
|
19
|
+
* list_contacts — find contacts by pipeline stage
|
|
20
|
+
* search — semantic search across workspace memories
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
24
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
import { validateConfig, get, post, patch, del } from "./client.js";
|
|
27
|
+
|
|
28
|
+
// ─── Context engineering helpers ──────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
// Activity types with known direction
|
|
31
|
+
const ALWAYS_OUT = new Set(["email_sent", "follow_up_sent", "proposal_sent"]);
|
|
32
|
+
const ALWAYS_IN = new Set(["website_visit", "content_download", "trial_started", "email_reply", "linkedin_replied"]);
|
|
33
|
+
const OUT_SOURCES = new Set(["agent", "mcp", "sdk", "api"]);
|
|
34
|
+
|
|
35
|
+
function actDir(a) {
|
|
36
|
+
if (ALWAYS_OUT.has(a.type)) return "out";
|
|
37
|
+
if (ALWAYS_IN.has(a.type)) return "in";
|
|
38
|
+
// LinkedIn webhook captures both sent and received — check description
|
|
39
|
+
if (a.source === "linkedin" && a.type === "linkedin_message") {
|
|
40
|
+
const d = (a.description || "").toLowerCase();
|
|
41
|
+
if (d.startsWith("linkedin message sent") || d.includes(" sent to ")) return "out";
|
|
42
|
+
return "in";
|
|
43
|
+
}
|
|
44
|
+
if (OUT_SOURCES.has(a.source)) return "out";
|
|
45
|
+
if (a.source) return "in"; // any other source = webhook-delivered = inbound
|
|
46
|
+
return "neutral";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ARROW = { out: "→", in: "←", neutral: "↔" };
|
|
50
|
+
|
|
51
|
+
// Higher = show first when capping 7-30d to top 3
|
|
52
|
+
const SIGNAL_WEIGHT = {
|
|
53
|
+
proposal_sent: 10, trial_started: 9, call_held: 8, meeting_held: 8,
|
|
54
|
+
email_reply: 6, email_sent: 6, follow_up_sent: 5,
|
|
55
|
+
linkedin_message: 4, content_download: 4,
|
|
56
|
+
website_visit: 3, linkedin_connected: 3, manual_note: 2,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function relAge(ts) {
|
|
60
|
+
const d = Math.floor((Date.now() - new Date(ts).getTime()) / 86400000);
|
|
61
|
+
if (d < 1) return "today";
|
|
62
|
+
if (d === 1) return "1d ago";
|
|
63
|
+
if (d < 30) return `${d}d ago`;
|
|
64
|
+
const m = Math.floor(d / 30);
|
|
65
|
+
if (m < 12) return `${m}mo ago`;
|
|
66
|
+
return `${Math.floor(m / 12)}y ago`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fmtShortDate(ts) {
|
|
70
|
+
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fmtType(t) {
|
|
74
|
+
return t.replace(/_/g, " ");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
validateConfig();
|
|
78
|
+
|
|
79
|
+
const server = new McpServer({
|
|
80
|
+
name: "nous",
|
|
81
|
+
version: "0.8.9",
|
|
82
|
+
description: "Nous — contact intelligence for GTM agents. Call get_contact before acting on any person. Call track and remember after every interaction.",
|
|
83
|
+
icons: [
|
|
84
|
+
{ src: "https://opennous.cloud/newlogoP.png", mimeType: "image/png", sizes: ["64x64"] },
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// TOOL: get_contact
|
|
90
|
+
// The one call you need. Returns everything known about a contact:
|
|
91
|
+
// identity, pipeline stage, memory summary, recent activities, and facts.
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
server.tool(
|
|
94
|
+
"get_contact",
|
|
95
|
+
"Get everything known about a contact — identity, pipeline stage, status (are you waiting on them or are they waiting on you?), AI summary, recent activities with direction arrows (← inbound / → outbound), and stored facts. Call this before writing any email, preparing for a call, or making a decision. Also call get_memories for outreach history. Pass email or contact_id.",
|
|
96
|
+
{
|
|
97
|
+
email: z.string().optional().describe("Contact's email address"),
|
|
98
|
+
contact_id: z.string().optional().describe("Contact UUID — use if you already have it"),
|
|
99
|
+
},
|
|
100
|
+
async ({ email, contact_id }) => {
|
|
101
|
+
if (!email && !contact_id) {
|
|
102
|
+
return { content: [{ type: "text", text: "Error: provide either email or contact_id." }] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const identifier = encodeURIComponent(contact_id || email);
|
|
106
|
+
const since90 = new Date(Date.now() - 90 * 86400000).toISOString();
|
|
107
|
+
|
|
108
|
+
const [c, actData] = await Promise.all([
|
|
109
|
+
get(`/v1/contacts/${identifier}`),
|
|
110
|
+
get(`/v1/contacts/${identifier}/activity`, { limit: 100, since: since90 }).catch(() => ({ activities: [] })),
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
const contactId = c.id || c.contact_id;
|
|
114
|
+
const name = c.name || c.email;
|
|
115
|
+
const header = [name, c.title, c.company].filter(Boolean).join(" · ");
|
|
116
|
+
const scores = [
|
|
117
|
+
`Stage: ${c.pipeline_stage || "identified"}`,
|
|
118
|
+
c.icp_score != null ? `ICP: ${c.icp_score}` : null,
|
|
119
|
+
c.deal_health_score != null ? `Health: ${c.deal_health_score}` : null,
|
|
120
|
+
].filter(Boolean).join(" | ");
|
|
121
|
+
|
|
122
|
+
const lines = [header, scores];
|
|
123
|
+
|
|
124
|
+
// ── Activity timeline — temporal compression ──────────────────────────────
|
|
125
|
+
const activities = actData.activities ?? [];
|
|
126
|
+
const now = Date.now();
|
|
127
|
+
const ms7 = 7 * 86400000;
|
|
128
|
+
const ms30 = 30 * 86400000;
|
|
129
|
+
|
|
130
|
+
const hot = []; // 0–7 days → full body + direction arrow
|
|
131
|
+
const warm = []; // 7–30 days → description + direction arrow, capped to top 3 by signal weight
|
|
132
|
+
const counts = {}; // 30–90 days → counts by type
|
|
133
|
+
|
|
134
|
+
for (const a of activities) {
|
|
135
|
+
const age = now - new Date(a.occurred_at).getTime();
|
|
136
|
+
if (age < ms7) hot.push(a);
|
|
137
|
+
else if (age < ms30) warm.push(a);
|
|
138
|
+
else counts[a.type] = (counts[a.type] || 0) + 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Status line — derived from most recent activity direction ─────────────
|
|
142
|
+
if (activities.length) {
|
|
143
|
+
const latest = activities[0];
|
|
144
|
+
const dir = actDir(latest);
|
|
145
|
+
const when = relAge(latest.occurred_at);
|
|
146
|
+
if (dir === "in") {
|
|
147
|
+
lines.push(`\nStatus: ← Waiting on your reply · ${when}`);
|
|
148
|
+
} else if (dir === "out") {
|
|
149
|
+
lines.push(`\nStatus: → You reached out · ${when}`);
|
|
150
|
+
} else {
|
|
151
|
+
lines.push(`\nStatus: ↔ Last touch · ${when}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (c.memory_summary || c.summary) {
|
|
156
|
+
lines.push(`\nSummary: ${c.memory_summary || c.summary}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (hot.length || warm.length || Object.keys(counts).length) {
|
|
160
|
+
if (hot.length) {
|
|
161
|
+
lines.push(`\nLast 7 days (${hot.length}):`);
|
|
162
|
+
for (const a of hot) {
|
|
163
|
+
const arrow = ARROW[actDir(a)];
|
|
164
|
+
const content = a.body || a.description || null;
|
|
165
|
+
lines.push(` ${arrow} ${fmtShortDate(a.occurred_at)} ${fmtType(a.type)}${content ? `: "${content}"` : ""}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (warm.length) {
|
|
169
|
+
const topWarm = warm
|
|
170
|
+
.slice()
|
|
171
|
+
.sort((a, b) => (SIGNAL_WEIGHT[b.type] ?? 0) - (SIGNAL_WEIGHT[a.type] ?? 0))
|
|
172
|
+
.slice(0, 3);
|
|
173
|
+
lines.push(`\n7–30 days (showing ${topWarm.length} of ${warm.length}):`);
|
|
174
|
+
for (const a of topWarm) {
|
|
175
|
+
const arrow = ARROW[actDir(a)];
|
|
176
|
+
const desc = a.description ? `: ${a.description}` : "";
|
|
177
|
+
lines.push(` ${arrow} ${fmtShortDate(a.occurred_at)} ${fmtType(a.type)}${desc}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (Object.keys(counts).length) {
|
|
181
|
+
const summary = Object.entries(counts)
|
|
182
|
+
.sort(([, a], [, b]) => b - a)
|
|
183
|
+
.map(([t, n]) => `${n}× ${fmtType(t)}`)
|
|
184
|
+
.join(" · ");
|
|
185
|
+
lines.push(`\nHistory (30–90d): ${summary}`);
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
lines.push("\nNo activity in the last 90 days.");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Facts — capped at 5, newest first with relative age ──────────────────
|
|
192
|
+
const allFacts = c.facts ?? [];
|
|
193
|
+
const contactFacts = allFacts.filter(f => f.scope !== "company");
|
|
194
|
+
const companyFacts = allFacts.filter(f => f.scope === "company");
|
|
195
|
+
|
|
196
|
+
if (contactFacts.length) {
|
|
197
|
+
const shown = contactFacts.slice(0, 5);
|
|
198
|
+
const extra = contactFacts.length - shown.length;
|
|
199
|
+
lines.push(`\nFacts${extra > 0 ? ` (+${extra} more)` : ""}:`);
|
|
200
|
+
for (const f of shown) {
|
|
201
|
+
const age = f.written_at ? ` · ${relAge(f.written_at)}` : "";
|
|
202
|
+
lines.push(` [${f.category}] ${f.content}${age}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (companyFacts.length) {
|
|
207
|
+
lines.push(`\nCompany facts:`);
|
|
208
|
+
for (const f of companyFacts) {
|
|
209
|
+
const age = f.written_at ? ` · ${relAge(f.written_at)}` : "";
|
|
210
|
+
lines.push(` [${f.category}] ${f.content}${age}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const text = lines.join("\n");
|
|
215
|
+
return {
|
|
216
|
+
content: [{
|
|
217
|
+
type: "text",
|
|
218
|
+
text: `${text}\n\n(contact_id: ${contactId}${c.company_id ? ` · company_id: ${c.company_id}` : ""})`,
|
|
219
|
+
}],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// TOOL: track
|
|
226
|
+
// Record that something happened — a call, email, meeting, website visit.
|
|
227
|
+
// For what you *learned*, also call remember.
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
const TRACK_NEXT_STEP = {
|
|
231
|
+
call_held: "Call remember if you learned something worth keeping.",
|
|
232
|
+
meeting_held: "Call remember if you learned something worth keeping.",
|
|
233
|
+
email_reply: "They replied — draft your response or update the pipeline stage.",
|
|
234
|
+
linkedin_replied: "They replied — draft your response or update the pipeline stage.",
|
|
235
|
+
proposal_sent: "Flag for follow-up if no reply in 3 days.",
|
|
236
|
+
follow_up_sent: "Consider updating the pipeline stage if they're not engaging.",
|
|
237
|
+
trial_started: "High-intent signal — consider moving to evaluating stage.",
|
|
238
|
+
linkedin_connected: "Good opener — send an intro message.",
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
server.tool(
|
|
242
|
+
"track",
|
|
243
|
+
"Record an interaction with a contact — email sent, call held, meeting completed, website visit, etc. Pass email or contact_id. If you learned something meaningful, also call remember.",
|
|
244
|
+
{
|
|
245
|
+
email: z.string().optional().describe("Contact's email address"),
|
|
246
|
+
contact_id: z.string().optional().describe("Contact UUID"),
|
|
247
|
+
type: z.enum([
|
|
248
|
+
"email_sent", "email_reply",
|
|
249
|
+
"call_held", "meeting_held",
|
|
250
|
+
"linkedin_message", "linkedin_connected", "linkedin_replied",
|
|
251
|
+
"follow_up_sent", "proposal_sent",
|
|
252
|
+
"website_visit", "content_download", "trial_started",
|
|
253
|
+
"manual_note",
|
|
254
|
+
]).describe("Activity type"),
|
|
255
|
+
description: z.string().optional().describe("Brief summary of what happened"),
|
|
256
|
+
occurred_at: z.string().optional().describe("ISO timestamp — defaults to now"),
|
|
257
|
+
},
|
|
258
|
+
async ({ email, contact_id, type, description, occurred_at }) => {
|
|
259
|
+
if (!email && !contact_id) {
|
|
260
|
+
return { content: [{ type: "text", text: "Error: provide either email or contact_id." }] };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const body = { source: "agent", type };
|
|
264
|
+
if (contact_id) body.contact_id = contact_id;
|
|
265
|
+
else body.email = email;
|
|
266
|
+
if (description) body.description = description;
|
|
267
|
+
if (occurred_at) body.occurred_at = occurred_at;
|
|
268
|
+
|
|
269
|
+
const result = await post("/v1/track", body);
|
|
270
|
+
|
|
271
|
+
const parts = [`Logged \`${type}\`${description ? `: "${description}"` : ""}.`];
|
|
272
|
+
|
|
273
|
+
if (result.created_contact) {
|
|
274
|
+
parts.push(`(New contact created — contact_id: ${result.contact_id})`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (result.stage_after && result.stage_before && result.stage_after !== result.stage_before) {
|
|
278
|
+
parts.push(`Stage advanced: ${result.stage_before} → ${result.stage_after}.`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const hint = TRACK_NEXT_STEP[type];
|
|
282
|
+
if (hint) parts.push(`Next: ${hint}`);
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
content: [{ type: "text", text: parts.join("\n") }],
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// TOOL: remember
|
|
292
|
+
// Store a fact you learned — about a person or their company.
|
|
293
|
+
// Write facts, not logs. "Concerned about integration complexity" = fact.
|
|
294
|
+
// "Sent email on Tuesday" = log (use track for that).
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
server.tool(
|
|
297
|
+
"remember",
|
|
298
|
+
"Store a fact — about a contact, their company, or your own workspace (ICP, product, market). Omit email and contact_id to store workspace-level facts. Pass email or contact_id to scope to a person. Set company=true to tag to their whole org. Similar existing facts in the same scope are automatically superseded — no duplicates accumulate.",
|
|
299
|
+
{
|
|
300
|
+
email: z.string().optional().describe("Contact's email — omit for workspace-level facts"),
|
|
301
|
+
contact_id: z.string().optional().describe("Contact UUID — omit for workspace-level facts"),
|
|
302
|
+
fact: z.string().describe("The fact to store — one clear, reusable sentence"),
|
|
303
|
+
category: z.enum(["ICP", "Product", "Pricing", "Market", "Competitors", "Team", "Patterns", "General"])
|
|
304
|
+
.optional()
|
|
305
|
+
.describe("Memory category (default: General)"),
|
|
306
|
+
company: z.boolean().optional().describe("Set true to tag this fact to the whole company, not just this person"),
|
|
307
|
+
},
|
|
308
|
+
async ({ email, contact_id, fact, category = "General", company = false }) => {
|
|
309
|
+
let body = { text: fact, category, source: "agent" };
|
|
310
|
+
|
|
311
|
+
if (company && (email || contact_id)) {
|
|
312
|
+
const identifier = encodeURIComponent(contact_id || email);
|
|
313
|
+
const c = await get(`/v1/contact/${identifier}`);
|
|
314
|
+
if (!c.company_id) {
|
|
315
|
+
return { content: [{ type: "text", text: "Cannot scope to company — contact has no company_id. Storing in workspace memory instead." }] };
|
|
316
|
+
}
|
|
317
|
+
body.company_id = c.company_id;
|
|
318
|
+
} else if (contact_id) {
|
|
319
|
+
body.contact_id = contact_id;
|
|
320
|
+
} else if (email) {
|
|
321
|
+
body.email = email;
|
|
322
|
+
}
|
|
323
|
+
// else: no email/contact_id = workspace-level fact
|
|
324
|
+
|
|
325
|
+
const result = await post("/v1/remember", body);
|
|
326
|
+
const stored = result.stored ?? 0;
|
|
327
|
+
|
|
328
|
+
if (stored === 0) {
|
|
329
|
+
return { content: [{ type: "text", text: "No new facts extracted — already known or too vague." }] };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const scope = company ? "company memory" : (contact_id || email) ? "contact memory" : "workspace memory";
|
|
333
|
+
const factList = (result.facts ?? []).map(f => {
|
|
334
|
+
const superseded = f.superseded ? " (superseded previous)" : "";
|
|
335
|
+
return `"${f.content}"${superseded}`;
|
|
336
|
+
}).join(", ");
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
content: [{
|
|
340
|
+
type: "text",
|
|
341
|
+
text: `Stored ${stored} fact${stored !== 1 ? "s" : ""} in ${scope} [${category}]: ${factList}`,
|
|
342
|
+
}],
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// TOOL: get_company
|
|
349
|
+
// Full company profile — org details + every contact at that company + company facts.
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
server.tool(
|
|
352
|
+
"get_company",
|
|
353
|
+
"Get the full profile for a company — org details, all contacts at that account, and company-level facts. Pass a company UUID (available on any contact as company_id).",
|
|
354
|
+
{
|
|
355
|
+
company_id: z.string().describe("Company UUID — get this from contact.company_id"),
|
|
356
|
+
},
|
|
357
|
+
async ({ company_id }) => {
|
|
358
|
+
const c = await get(`/v1/company/${encodeURIComponent(company_id)}`);
|
|
359
|
+
|
|
360
|
+
const lines = [
|
|
361
|
+
`${c.name}${c.domain ? ` (${c.domain})` : ""}`,
|
|
362
|
+
[
|
|
363
|
+
c.industry ? `Industry: ${c.industry}` : null,
|
|
364
|
+
c.employee_count ? `Size: ${c.employee_count}` : null,
|
|
365
|
+
c.deal_health_score != null ? `Health: ${c.deal_health_score}` : null,
|
|
366
|
+
].filter(Boolean).join(" | "),
|
|
367
|
+
];
|
|
368
|
+
|
|
369
|
+
const contacts = c.contacts ?? [];
|
|
370
|
+
if (contacts.length) {
|
|
371
|
+
lines.push(`\nContacts (${contacts.length} of ${c.total_contacts ?? contacts.length}):`);
|
|
372
|
+
for (const con of contacts) {
|
|
373
|
+
const role = con.title ? ` · ${con.title}` : "";
|
|
374
|
+
lines.push(` ${con.name || con.email}${role} — ${con.pipeline_stage || "identified"} (${con.contact_id || con.id})`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const facts = c.facts ?? [];
|
|
379
|
+
if (facts.length) {
|
|
380
|
+
lines.push(`\nCompany facts:`);
|
|
381
|
+
for (const f of facts) {
|
|
382
|
+
const age = f.written_at ? ` · ${relAge(f.written_at)}` : "";
|
|
383
|
+
lines.push(` [${f.category}] ${f.content}${age}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
content: [{ type: "text", text: `${lines.join("\n")}\n\n(company_id: ${c.company_id})` }],
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// TOOL: search
|
|
395
|
+
// Semantic search across workspace memories. Scope to one contact or search workspace-wide.
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
server.tool(
|
|
398
|
+
"search",
|
|
399
|
+
"Semantic search across all stored facts in the workspace. Scope to one contact or company, or omit to search workspace-wide. Useful for finding context before drafting a message or preparing for a call.",
|
|
400
|
+
{
|
|
401
|
+
q: z.string().describe("Search query — e.g. 'budget concerns', 'Salesforce migration'"),
|
|
402
|
+
contact_id: z.string().optional().describe("Scope to one contact — uses lenient matching (threshold 0.45)"),
|
|
403
|
+
company_id: z.string().optional().describe("Scope to one company"),
|
|
404
|
+
limit: z.number().min(1).max(20).optional().describe("Max results (default 10)"),
|
|
405
|
+
},
|
|
406
|
+
async ({ q, contact_id, company_id, limit = 10 }) => {
|
|
407
|
+
const body = { q, limit };
|
|
408
|
+
if (contact_id) body.contact_id = contact_id;
|
|
409
|
+
if (company_id) body.company_id = company_id;
|
|
410
|
+
|
|
411
|
+
const data = await post("/v1/search", body);
|
|
412
|
+
const results = data.results ?? [];
|
|
413
|
+
|
|
414
|
+
if (!results.length) {
|
|
415
|
+
return { content: [{ type: "text", text: `No memories found for "${q}".` }] };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const lines = results.map(r => {
|
|
419
|
+
const age = r.written_at ? ` · ${relAge(r.written_at)}` : "";
|
|
420
|
+
const scope = r.scope === "contact" && r.contact_id ? ` · contact:${r.contact_id}`
|
|
421
|
+
: r.scope === "company" && r.company_id ? ` · company:${r.company_id}`
|
|
422
|
+
: r.scope === "workspace" ? ` · workspace`
|
|
423
|
+
: "";
|
|
424
|
+
return ` [${r.category}]${age}${scope} ${r.content}`;
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
content: [{
|
|
429
|
+
type: "text",
|
|
430
|
+
text: `Search results for "${q}" (${results.length}):\n${lines.join("\n")}`,
|
|
431
|
+
}],
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// TOOL: list_contacts
|
|
438
|
+
// Find contacts by pipeline stage — useful for identifying who to reach out to next.
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
server.tool(
|
|
441
|
+
"list_contacts",
|
|
442
|
+
"List contacts sorted by urgency (high ICP + longest since last touch first). Use stage to focus on a specific pipeline stage. Contacts marked !! are high-ICP and have gone cold — prioritize those.",
|
|
443
|
+
{
|
|
444
|
+
stage: z.enum(["identified", "aware", "interested", "evaluating", "client"])
|
|
445
|
+
.optional()
|
|
446
|
+
.describe("Filter by pipeline stage — omit to list all"),
|
|
447
|
+
filter: z.enum(["hot", "engaged"]).optional()
|
|
448
|
+
.describe("hot = active in last 14d with health≥45; engaged = active in last 60d"),
|
|
449
|
+
limit: z.number().min(1).max(50).optional().describe("Max contacts to return (default 20)"),
|
|
450
|
+
},
|
|
451
|
+
async ({ stage, filter, limit = 20 }) => {
|
|
452
|
+
const params = { limit, sort: "urgency" };
|
|
453
|
+
if (stage) params.pipeline_stage = stage;
|
|
454
|
+
if (filter) params.filter = filter;
|
|
455
|
+
|
|
456
|
+
const data = await get("/v1/contacts", params);
|
|
457
|
+
const contacts = data.contacts ?? [];
|
|
458
|
+
const total = data.total ?? contacts.length;
|
|
459
|
+
|
|
460
|
+
if (!contacts.length) {
|
|
461
|
+
return { content: [{ type: "text", text: stage ? `No contacts in stage "${stage}".` : "No contacts found." }] };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const daysSince = c => c.last_activity_at
|
|
465
|
+
? Math.floor((Date.now() - new Date(c.last_activity_at).getTime()) / 86400000)
|
|
466
|
+
: 999;
|
|
467
|
+
|
|
468
|
+
const shown = contacts;
|
|
469
|
+
|
|
470
|
+
const lines = shown.map(c => {
|
|
471
|
+
const name = c.name || c.email;
|
|
472
|
+
const company = c.company ? ` @ ${c.company}` : "";
|
|
473
|
+
const icp = c.icp_score != null ? ` · ICP:${c.icp_score}` : "";
|
|
474
|
+
const d = daysSince(c);
|
|
475
|
+
const age = d === 999 ? " · never touched" : ` · ${relAge(c.last_activity_at)}`;
|
|
476
|
+
const flag = d > 7 && (c.icp_score ?? 0) >= 70 ? "!! " : " ";
|
|
477
|
+
return `${flag}${name}${company} — ${c.pipeline_stage || "identified"}${icp}${age} (${c.id})`;
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const header = `Contacts${stage ? ` — ${stage}` : ""}${filter ? ` [${filter}]` : ""} (${shown.length} of ${total}, sorted by urgency):`;
|
|
481
|
+
const legend = shown.some(c => daysSince(c) > 7 && (c.icp_score ?? 0) >= 70)
|
|
482
|
+
? "!! = high ICP, gone cold\n"
|
|
483
|
+
: "";
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
content: [{
|
|
487
|
+
type: "text",
|
|
488
|
+
text: `${header}\n${legend}${lines.join("\n")}`,
|
|
489
|
+
}],
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// TOOL: get_memories
|
|
496
|
+
// Load all workspace-level facts — ICP, product, market, competitive intel.
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
server.tool(
|
|
499
|
+
"get_memories",
|
|
500
|
+
"Load all workspace-level facts — your ICP, product description, pricing, market positioning, and competitive intel. Call this before drafting outreach, preparing a pitch, or any task that requires knowing your own company context. Not for contact or company facts — use get_contact for those.",
|
|
501
|
+
{
|
|
502
|
+
category: z.enum(["ICP", "Product", "Pricing", "Market", "Competitors", "Team", "Patterns", "General"])
|
|
503
|
+
.optional()
|
|
504
|
+
.describe("Filter by category — omit to get all"),
|
|
505
|
+
limit: z.number().min(1).max(200).optional().describe("Max facts to return (default 50)"),
|
|
506
|
+
},
|
|
507
|
+
async ({ category, limit = 50 }) => {
|
|
508
|
+
const params = { limit };
|
|
509
|
+
if (category) params.category = category;
|
|
510
|
+
|
|
511
|
+
const data = await get("/v1/memories", params);
|
|
512
|
+
const memories = data.memories ?? [];
|
|
513
|
+
|
|
514
|
+
if (!memories.length) {
|
|
515
|
+
return { content: [{ type: "text", text: `No workspace facts stored yet${category ? ` in category "${category}"` : ""}. Use remember (without a contact) to store ICP, product, or market facts.` }] };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const byCategory = {};
|
|
519
|
+
for (const m of memories) {
|
|
520
|
+
if (!byCategory[m.category]) byCategory[m.category] = [];
|
|
521
|
+
byCategory[m.category].push(m.content);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const lines = [];
|
|
525
|
+
for (const [cat, facts] of Object.entries(byCategory)) {
|
|
526
|
+
lines.push(`[${cat}]`);
|
|
527
|
+
for (const f of facts) lines.push(` • ${f}`);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
content: [{
|
|
532
|
+
type: "text",
|
|
533
|
+
text: `Workspace knowledge (${memories.length} fact${memories.length !== 1 ? "s" : ""}):\n\n${lines.join("\n")}`,
|
|
534
|
+
}],
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
// TOOL: create_contact
|
|
541
|
+
// Add a new contact with full profile fields. Returns the new contact_id.
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
server.tool(
|
|
544
|
+
"create_contact",
|
|
545
|
+
"Create a new contact with their full profile — name, title, company, phone, LinkedIn, and notes. Use when you have a new person to add before tracking any activity. Returns an error if the email already exists.",
|
|
546
|
+
{
|
|
547
|
+
email: z.string().describe("Contact's email address (required, must be unique)"),
|
|
548
|
+
first_name: z.string().optional().describe("First name"),
|
|
549
|
+
last_name: z.string().optional().describe("Last name"),
|
|
550
|
+
company: z.string().optional().describe("Company name"),
|
|
551
|
+
job_title: z.string().optional().describe("Job title or role"),
|
|
552
|
+
phone: z.string().optional().describe("Phone number"),
|
|
553
|
+
linkedin_url: z.string().optional().describe("LinkedIn profile URL"),
|
|
554
|
+
notes: z.string().optional().describe("Free-form notes about this contact"),
|
|
555
|
+
},
|
|
556
|
+
async ({ email, first_name, last_name, company, job_title, phone, linkedin_url, notes }) => {
|
|
557
|
+
const result = await post("/v1/contacts", {
|
|
558
|
+
email, first_name, last_name, company, job_title, phone, linkedin_url, notes,
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
const name = result.name || result.email;
|
|
562
|
+
const meta = [result.job_title, result.company].filter(Boolean).join(" · ");
|
|
563
|
+
return {
|
|
564
|
+
content: [{
|
|
565
|
+
type: "text",
|
|
566
|
+
text: [
|
|
567
|
+
`Created contact: ${name}`,
|
|
568
|
+
meta ? meta : null,
|
|
569
|
+
`contact_id: ${result.id}`,
|
|
570
|
+
`Stage: ${result.pipeline_stage}`,
|
|
571
|
+
].filter(Boolean).join("\n"),
|
|
572
|
+
}],
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
// TOOL: update_contact
|
|
579
|
+
// Update profile fields on an existing contact.
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
server.tool(
|
|
582
|
+
"update_contact",
|
|
583
|
+
"Update one or more profile fields on an existing contact — name, title, company, phone, LinkedIn, notes. Pass email or contact_id to identify the contact, then any fields to change. Only provided fields are updated.",
|
|
584
|
+
{
|
|
585
|
+
email: z.string().optional().describe("Contact's email address (to identify them)"),
|
|
586
|
+
contact_id: z.string().optional().describe("Contact UUID (alternative to email)"),
|
|
587
|
+
first_name: z.string().optional().describe("Updated first name"),
|
|
588
|
+
last_name: z.string().optional().describe("Updated last name"),
|
|
589
|
+
company: z.string().optional().describe("Updated company name"),
|
|
590
|
+
job_title: z.string().optional().describe("Updated job title or role"),
|
|
591
|
+
phone: z.string().optional().describe("Updated phone number"),
|
|
592
|
+
linkedin_url: z.string().optional().describe("Updated LinkedIn profile URL"),
|
|
593
|
+
notes: z.string().optional().describe("Updated free-form notes"),
|
|
594
|
+
},
|
|
595
|
+
async ({ email, contact_id, first_name, last_name, company, job_title, phone, linkedin_url, notes }) => {
|
|
596
|
+
if (!email && !contact_id) {
|
|
597
|
+
return { content: [{ type: "text", text: "Error: provide either email or contact_id." }] };
|
|
598
|
+
}
|
|
599
|
+
const identifier = encodeURIComponent(contact_id || email);
|
|
600
|
+
const result = await patch(`/v1/contacts/${identifier}`, {
|
|
601
|
+
first_name, last_name, company, job_title, phone, linkedin_url, notes,
|
|
602
|
+
});
|
|
603
|
+
const name = result.name || result.email;
|
|
604
|
+
const meta = [result.job_title, result.company].filter(Boolean).join(" · ");
|
|
605
|
+
return {
|
|
606
|
+
content: [{
|
|
607
|
+
type: "text",
|
|
608
|
+
text: [
|
|
609
|
+
`Updated contact: ${name}`,
|
|
610
|
+
meta ? meta : null,
|
|
611
|
+
`contact_id: ${result.id}`,
|
|
612
|
+
].filter(Boolean).join("\n"),
|
|
613
|
+
}],
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
// TOOL: delete_contact
|
|
620
|
+
// Permanently remove a contact and all their associated data.
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
server.tool(
|
|
623
|
+
"delete_contact",
|
|
624
|
+
"Permanently delete a contact and all their data — activities and memories. Use for duplicates, test entries, or explicit removal requests. Cannot be undone. Pass email or contact_id.",
|
|
625
|
+
{
|
|
626
|
+
email: z.string().optional().describe("Contact's email address"),
|
|
627
|
+
contact_id: z.string().optional().describe("Contact UUID"),
|
|
628
|
+
},
|
|
629
|
+
async ({ email, contact_id }) => {
|
|
630
|
+
if (!email && !contact_id) {
|
|
631
|
+
return { content: [{ type: "text", text: "Error: provide either email or contact_id." }] };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const identifier = encodeURIComponent(contact_id || email);
|
|
635
|
+
const result = await del(`/v1/contacts/${identifier}`);
|
|
636
|
+
|
|
637
|
+
return {
|
|
638
|
+
content: [{
|
|
639
|
+
type: "text",
|
|
640
|
+
text: `Deleted contact ${result.email} (${result.contact_id}).`,
|
|
641
|
+
}],
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
// ---------------------------------------------------------------------------
|
|
647
|
+
// TOOL: delete_memory
|
|
648
|
+
// Soft-delete a workspace memory by ID (marks is_active: false).
|
|
649
|
+
// Use to remove outdated or conflicting facts found via get_memories.
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
server.tool(
|
|
652
|
+
"delete_memory",
|
|
653
|
+
"Delete a workspace memory by ID — marks it inactive so it no longer appears in get_memories or search. Use to clean up outdated, conflicting, or incorrect facts. Get the memory ID from get_memories output.",
|
|
654
|
+
{
|
|
655
|
+
memory_id: z.string().describe("Memory UUID — get this from get_memories output"),
|
|
656
|
+
},
|
|
657
|
+
async ({ memory_id }) => {
|
|
658
|
+
const result = await del(`/v1/memory/${encodeURIComponent(memory_id)}`);
|
|
659
|
+
return {
|
|
660
|
+
content: [{
|
|
661
|
+
type: "text",
|
|
662
|
+
text: `Deleted memory (${result.id}): "${result.content}"`,
|
|
663
|
+
}],
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
// ---------------------------------------------------------------------------
|
|
669
|
+
const transport = new StdioServerTransport();
|
|
670
|
+
await server.connect(transport);
|