@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/package.json
CHANGED
|
@@ -1,30 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opennous/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Nous MCP Server —
|
|
3
|
+
"version": "0.16.0",
|
|
4
|
+
"description": "Nous MCP Server — Customer graph for GTM agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"nous-mcp": "src/index.js"
|
|
8
8
|
},
|
|
9
9
|
"main": "./src/index.js",
|
|
10
|
-
"files": [
|
|
11
|
-
"src"
|
|
12
|
-
],
|
|
13
|
-
"keywords": [
|
|
14
|
-
"mcp",
|
|
15
|
-
"nous",
|
|
16
|
-
"memory",
|
|
17
|
-
"crm",
|
|
18
|
-
"gtm",
|
|
19
|
-
"agents"
|
|
20
|
-
],
|
|
21
|
-
"dependencies": {
|
|
22
|
-
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
23
|
-
"zod": "^3.23.8"
|
|
24
|
-
},
|
|
10
|
+
"files": ["src"],
|
|
25
11
|
"scripts": {
|
|
26
12
|
"dev": "node --watch src/index.js",
|
|
27
13
|
"start": "node src/index.js",
|
|
14
|
+
"dev:http": "node --watch src/http.js",
|
|
15
|
+
"start:http": "node src/http.js",
|
|
28
16
|
"typecheck": "echo 'no ts in mcp — skipping'"
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["mcp", "nous", "memory", "crm", "gtm", "agents"],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
21
|
+
"zod": "^3.23.8"
|
|
29
22
|
}
|
|
30
|
-
}
|
|
23
|
+
}
|
package/src/client.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Nous API Client
|
|
3
3
|
* Thin HTTP wrapper that handles auth, workspace context, and error handling.
|
|
4
|
+
*
|
|
5
|
+
* The API key is resolved per request: in the hosted HTTP server each request
|
|
6
|
+
* carries its own Bearer key (scoped via AsyncLocalStorage); in the stdio bin
|
|
7
|
+
* there is one key from the env. currentApiKey() checks the ALS store first,
|
|
8
|
+
* then falls back to NOUS_API_KEY, so tool handlers stay key-agnostic.
|
|
4
9
|
*/
|
|
5
10
|
|
|
11
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
12
|
+
|
|
6
13
|
// Resolve an env var defensively — Claude Code plugins use ${user_config.X}
|
|
7
14
|
// substitution; when an optional userConfig field is left blank, the literal
|
|
8
15
|
// "${user_config.field}" string can leak through. Treat any value that looks
|
|
@@ -15,10 +22,23 @@ function resolvedEnv(name) {
|
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
const API_URL = resolvedEnv("NOUS_API_URL") || "https://api.opennous.cloud";
|
|
18
|
-
const API_KEY = resolvedEnv("NOUS_API_KEY");
|
|
19
25
|
|
|
26
|
+
// Per-request key context for the hosted HTTP server. Empty in stdio mode.
|
|
27
|
+
export const apiKeyStore = new AsyncLocalStorage();
|
|
28
|
+
|
|
29
|
+
// Run `fn` with `apiKey` bound for the duration of its async execution, so any
|
|
30
|
+
// request() call inside it uses that key.
|
|
31
|
+
export function runWithApiKey(apiKey, fn) {
|
|
32
|
+
return apiKeyStore.run({ apiKey }, fn);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function currentApiKey() {
|
|
36
|
+
return apiKeyStore.getStore()?.apiKey ?? resolvedEnv("NOUS_API_KEY");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// stdio-only preflight: the env key must be present at startup.
|
|
20
40
|
export function validateConfig() {
|
|
21
|
-
if (!
|
|
41
|
+
if (!resolvedEnv("NOUS_API_KEY")) {
|
|
22
42
|
throw new Error(
|
|
23
43
|
"NOUS_API_KEY is required. Get yours at opennous.cloud → Settings → API Keys"
|
|
24
44
|
);
|
|
@@ -26,6 +46,11 @@ export function validateConfig() {
|
|
|
26
46
|
}
|
|
27
47
|
|
|
28
48
|
async function request(method, path, { body, query } = {}) {
|
|
49
|
+
const apiKey = currentApiKey();
|
|
50
|
+
if (!apiKey) {
|
|
51
|
+
throw new Error("Missing Nous API key. Pass it as an Authorization: Bearer header.");
|
|
52
|
+
}
|
|
53
|
+
|
|
29
54
|
const url = new URL(path, API_URL);
|
|
30
55
|
|
|
31
56
|
if (query) {
|
|
@@ -37,7 +62,7 @@ async function request(method, path, { body, query } = {}) {
|
|
|
37
62
|
}
|
|
38
63
|
|
|
39
64
|
const headers = {
|
|
40
|
-
Authorization: `Bearer ${
|
|
65
|
+
Authorization: `Bearer ${apiKey}`,
|
|
41
66
|
"Content-Type": "application/json",
|
|
42
67
|
"X-Nous-Client": "mcp",
|
|
43
68
|
};
|
package/src/http.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Nous MCP Server — hosted, multi-tenant HTTP entrypoint (mcp.opennous.cloud).
|
|
5
|
+
*
|
|
6
|
+
* Serves the same seven tools (server.js) over the MCP Streamable HTTP transport,
|
|
7
|
+
* so cloud clients that cannot launch a local process — n8n cloud above all —
|
|
8
|
+
* can connect by pasting one URL plus their workspace API key.
|
|
9
|
+
*
|
|
10
|
+
* Auth: each request carries its own `Authorization: Bearer pk_...` (the same
|
|
11
|
+
* workspace key the REST API already validates). A `?key=pk_...` query param is
|
|
12
|
+
* accepted as a fallback for url-only clients (less secure: it can land in logs).
|
|
13
|
+
* The key is bound for the request via AsyncLocalStorage (runWithApiKey), so the
|
|
14
|
+
* tool handlers stay key-agnostic.
|
|
15
|
+
*
|
|
16
|
+
* Stateless: a fresh server + transport per request — clean tenant isolation,
|
|
17
|
+
* no session store.
|
|
18
|
+
*
|
|
19
|
+
* Env:
|
|
20
|
+
* PORT — listen port (default 3002)
|
|
21
|
+
* NOUS_API_URL — API base URL (default https://api.opennous.cloud; in-cluster: http://api:3000)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import http from "node:http";
|
|
25
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
26
|
+
import { runWithApiKey } from "./client.js";
|
|
27
|
+
import { createServer, SERVER_VERSION } from "./server.js";
|
|
28
|
+
|
|
29
|
+
const PORT = Number(process.env.PORT) || 3002;
|
|
30
|
+
const MCP_PATH = "/mcp";
|
|
31
|
+
|
|
32
|
+
const CORS_HEADERS = {
|
|
33
|
+
"Access-Control-Allow-Origin": "*",
|
|
34
|
+
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
35
|
+
"Access-Control-Allow-Headers": "Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version",
|
|
36
|
+
"Access-Control-Expose-Headers": "Mcp-Session-Id",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function sendJson(res, status, payload) {
|
|
40
|
+
const text = JSON.stringify(payload);
|
|
41
|
+
res.writeHead(status, { ...CORS_HEADERS, "Content-Type": "application/json" });
|
|
42
|
+
res.end(text);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// JSON-RPC shaped error so MCP clients render it cleanly.
|
|
46
|
+
function rpcError(res, status, message, id = null) {
|
|
47
|
+
sendJson(res, status, { jsonrpc: "2.0", error: { code: -32000, message }, id });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function extractApiKey(req, url) {
|
|
51
|
+
const auth = req.headers["authorization"];
|
|
52
|
+
if (auth && auth.startsWith("Bearer ")) return auth.slice(7).trim();
|
|
53
|
+
const qp = url.searchParams.get("key");
|
|
54
|
+
if (qp) return qp.trim();
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readBody(req) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const chunks = [];
|
|
61
|
+
req.on("data", (c) => chunks.push(c));
|
|
62
|
+
req.on("end", () => {
|
|
63
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
64
|
+
if (!raw) return resolve(undefined);
|
|
65
|
+
try { resolve(JSON.parse(raw)); }
|
|
66
|
+
catch (e) { reject(e); }
|
|
67
|
+
});
|
|
68
|
+
req.on("error", reject);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const httpServer = http.createServer(async (req, res) => {
|
|
73
|
+
const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
|
|
74
|
+
|
|
75
|
+
if (req.method === "OPTIONS") {
|
|
76
|
+
res.writeHead(204, CORS_HEADERS);
|
|
77
|
+
return res.end();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (url.pathname === "/health" || url.pathname === "/") {
|
|
81
|
+
return sendJson(res, 200, { status: "ok", server: "nous-mcp", version: SERVER_VERSION });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (url.pathname !== MCP_PATH) {
|
|
85
|
+
return rpcError(res, 404, "Not found. The MCP endpoint is POST /mcp.");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const apiKey = extractApiKey(req, url);
|
|
89
|
+
if (!apiKey) {
|
|
90
|
+
return rpcError(res, 401, "Missing API key. Send Authorization: Bearer <your Nous API key>.");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let body;
|
|
94
|
+
try {
|
|
95
|
+
body = req.method === "POST" ? await readBody(req) : undefined;
|
|
96
|
+
} catch {
|
|
97
|
+
return rpcError(res, 400, "Invalid JSON body.");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Fresh server + transport per request (stateless), with the key bound for
|
|
101
|
+
// the whole async handling so every tool call uses this tenant's key.
|
|
102
|
+
await runWithApiKey(apiKey, async () => {
|
|
103
|
+
const server = createServer();
|
|
104
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
105
|
+
res.on("close", () => {
|
|
106
|
+
transport.close();
|
|
107
|
+
server.close();
|
|
108
|
+
});
|
|
109
|
+
try {
|
|
110
|
+
await server.connect(transport);
|
|
111
|
+
await transport.handleRequest(req, res, body);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (!res.headersSent) {
|
|
114
|
+
rpcError(res, 500, `MCP error: ${err?.message ?? "unknown"}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
httpServer.listen(PORT, () => {
|
|
121
|
+
console.log(`Nous MCP (HTTP) v${SERVER_VERSION} listening on :${PORT}${MCP_PATH}`);
|
|
122
|
+
});
|
package/src/index.js
CHANGED
|
@@ -1,350 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Nous MCP Server —
|
|
4
|
+
* Nous MCP Server — stdio entrypoint (published as @opennous/mcp).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Runs locally and is launched by the client via `npx -y @opennous/mcp`. The
|
|
7
|
+
* workspace API key comes from the env. For the hosted, multi-tenant HTTP
|
|
8
|
+
* variant see http.js; the tools themselves live in server.js.
|
|
9
9
|
*
|
|
10
10
|
* Required env:
|
|
11
11
|
* NOUS_API_KEY — workspace API key (Settings -> API Keys)
|
|
12
12
|
* Optional:
|
|
13
13
|
* NOUS_API_URL — API base URL (default: https://api.opennous.cloud)
|
|
14
|
-
*
|
|
15
|
-
* Tools (all v2 — thin clients of the Context API):
|
|
16
|
-
* get_context — engineered context for a task (draft_email, follow_up, ...)
|
|
17
|
-
* get_account — the full account record: every claim + the timeline
|
|
18
|
-
* record — record what happened / what you learned (observe, never update)
|
|
19
|
-
* query — retrieve + summarise a corpus of activity across many people
|
|
20
|
-
* attention — what needs your attention (accounts gone quiet, facts decayed)
|
|
21
|
-
* verify — re-check a fact before acting on it
|
|
22
|
-
* get_workspace_facts — workspace-level facts (ICP, market, pricing, product, competitors)
|
|
23
14
|
*/
|
|
24
15
|
|
|
25
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
26
16
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
function relAge(ts) {
|
|
33
|
-
if (!ts) return "—";
|
|
34
|
-
const d = Math.floor((Date.now() - new Date(ts).getTime()) / 86400000);
|
|
35
|
-
if (d < 1) return "today";
|
|
36
|
-
if (d === 1) return "1d ago";
|
|
37
|
-
if (d < 30) return `${d}d ago`;
|
|
38
|
-
const m = Math.floor(d / 30);
|
|
39
|
-
if (m < 12) return `${m}mo ago`;
|
|
40
|
-
return `${Math.floor(m / 12)}y ago`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const fmtType = (p) => (p || "").replace(/^interaction\./, "").replace(/_/g, " ");
|
|
44
|
-
const fmtVal = (v) => (v != null && typeof v === "object") ? JSON.stringify(v) : String(v ?? "");
|
|
45
|
-
const pct = (c) => `${Math.round((c ?? 0) * 100)}%`;
|
|
17
|
+
import { validateConfig } from "./client.js";
|
|
18
|
+
import { createServer } from "./server.js";
|
|
46
19
|
|
|
47
20
|
validateConfig();
|
|
48
21
|
|
|
49
|
-
const server =
|
|
50
|
-
name: "nous",
|
|
51
|
-
version: "0.10.1",
|
|
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, and open predictions. Call this before drafting outreach, preparing for a meeting, " +
|
|
70
|
-
"or making any decision about a person. A fact's freshness tells you whether to trust it: 'fresh' " +
|
|
71
|
-
"act on it, 'suspect'/'expired' verify first.",
|
|
72
|
-
{
|
|
73
|
-
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."),
|
|
74
|
-
intent: z.enum(["draft_email", "follow_up", "meeting_prep", "call_prep", "account_review"])
|
|
75
|
-
.optional()
|
|
76
|
-
.describe("What you are about to do — shapes which context surfaces (default: account_review)"),
|
|
77
|
-
budget_tokens: z.number().optional().describe("Approximate token budget for the context block"),
|
|
78
|
-
},
|
|
79
|
-
async ({ focus, intent, budget_tokens }) => {
|
|
80
|
-
const ctx = await post("/v2/context", { focus, intent: intent ?? "account_review", budget_tokens });
|
|
81
|
-
|
|
82
|
-
// a name matched several people — surface the candidates to choose from
|
|
83
|
-
if (ctx.status === "ambiguous") {
|
|
84
|
-
const opts = (ctx.candidates ?? []).map(c =>
|
|
85
|
-
` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
|
|
86
|
-
return { content: [{ type: "text", text:
|
|
87
|
-
`"${focus}" matches several people. Call get_context again with one of these entity ids:\n${opts}` }] };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const lines = [ctx.summary, ""];
|
|
91
|
-
|
|
92
|
-
if (ctx.claims?.length) {
|
|
93
|
-
lines.push(`FACTS (${ctx.meta?.claims_returned ?? ctx.claims.length}):`);
|
|
94
|
-
for (const c of ctx.claims) {
|
|
95
|
-
lines.push(` ${c.property}: ${fmtVal(c.value)} [${pct(c.confidence)} · ${c.freshness}]`);
|
|
96
|
-
}
|
|
97
|
-
lines.push("");
|
|
98
|
-
}
|
|
99
|
-
if (ctx.workspace?.length) {
|
|
100
|
-
lines.push("YOUR CONTEXT (ICP / product / positioning):");
|
|
101
|
-
for (const w of ctx.workspace) lines.push(` ${w.property}: ${fmtVal(w.value)}`);
|
|
102
|
-
lines.push("");
|
|
103
|
-
}
|
|
104
|
-
if (ctx.timeline?.length) {
|
|
105
|
-
lines.push("TIMELINE:");
|
|
106
|
-
for (const t of ctx.timeline) {
|
|
107
|
-
if (t.tier === "count") lines.push(` ${t.count}× ${fmtType(t.type)}`);
|
|
108
|
-
else lines.push(` ${relAge(t.when)} ${fmtType(t.type)}${t.summary ? `: ${t.summary}` : ""}`);
|
|
109
|
-
}
|
|
110
|
-
lines.push("");
|
|
111
|
-
}
|
|
112
|
-
if (ctx.stakeholders?.length) {
|
|
113
|
-
lines.push("STAKEHOLDERS:");
|
|
114
|
-
for (const s of ctx.stakeholders) lines.push(` ${s.name ?? "—"} — ${s.role ?? ""}`);
|
|
115
|
-
lines.push("");
|
|
116
|
-
}
|
|
117
|
-
if (ctx.predictions?.length) {
|
|
118
|
-
lines.push("PREDICTIONS:");
|
|
119
|
-
for (const p of ctx.predictions) {
|
|
120
|
-
lines.push(` ${p.kind}: ${fmtVal(p.value)} (${pct(p.confidence)})`);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return {
|
|
124
|
-
content: [{ type: "text", text: `${lines.join("\n").trim()}\n\n(entity_id: ${ctx.entity?.id})` }],
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
);
|
|
128
|
-
|
|
129
|
-
// ===========================================================================
|
|
130
|
-
// TOOL: get_account — GET /v2/accounts/:id
|
|
131
|
-
// The full account-record projection. For a focused view, prefer get_context.
|
|
132
|
-
// ===========================================================================
|
|
133
|
-
server.tool(
|
|
134
|
-
"get_account",
|
|
135
|
-
"Get the full account record for a person or company — every known fact (claim) with its " +
|
|
136
|
-
"confidence and freshness, plus the recent activity timeline. Pass an email or entity UUID. " +
|
|
137
|
-
"For a task-specific, ranked view, prefer get_context.",
|
|
138
|
-
{ id: z.string().describe("Email address or entity UUID") },
|
|
139
|
-
async ({ id }) => {
|
|
140
|
-
const rec = await get(`/v2/accounts/${encodeURIComponent(id)}`);
|
|
141
|
-
const lines = [`${rec.type} · ${rec.entity_id}`, ""];
|
|
142
|
-
|
|
143
|
-
const claims = Object.values(rec.claims ?? {});
|
|
144
|
-
if (claims.length) {
|
|
145
|
-
lines.push(`FACTS (${claims.length}):`);
|
|
146
|
-
for (const c of claims) {
|
|
147
|
-
lines.push(` ${c.property}: ${fmtVal(c.value)} [${pct(c.confidence)} · ${c.freshness}]`);
|
|
148
|
-
}
|
|
149
|
-
lines.push("");
|
|
150
|
-
}
|
|
151
|
-
const obs = rec.recent_observations ?? [];
|
|
152
|
-
if (obs.length) {
|
|
153
|
-
lines.push(`TIMELINE (${obs.length}):`);
|
|
154
|
-
for (const o of obs.slice(0, 30)) {
|
|
155
|
-
lines.push(` ${relAge(o.observed_at)} ${fmtType(o.property)}`);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
159
|
-
}
|
|
160
|
-
);
|
|
161
|
-
|
|
162
|
-
// ===========================================================================
|
|
163
|
-
// TOOL: record — POST /v2/observations
|
|
164
|
-
// The single write verb. You observe — Nous derives the updated facts.
|
|
165
|
-
// ===========================================================================
|
|
166
|
-
server.tool(
|
|
167
|
-
"record",
|
|
168
|
-
"Record what happened or what you learned about a person or company. You never overwrite " +
|
|
169
|
-
"anything — you observe, and Nous derives the updated facts. Use kind:'event' for an interaction " +
|
|
170
|
-
"(property like 'interaction.email_sent', 'interaction.call_held', 'interaction.email_reply') and " +
|
|
171
|
-
"kind:'state' for a fact (property like 'job_title', 'deal.proposal_amount'). Examples — sent an " +
|
|
172
|
-
"email: {kind:'event',property:'interaction.email_sent',value:{description:'intro email'}}; " +
|
|
173
|
-
"learned their title changed: {kind:'state',property:'job_title',value:'VP of Engineering'}; " +
|
|
174
|
-
"a fact ended (they left): {kind:'state',property:'job_title',value:null}.",
|
|
175
|
-
{
|
|
176
|
-
focus: z.string().describe("Email address or entity UUID of the person or company"),
|
|
177
|
-
observations: z.array(z.object({
|
|
178
|
-
kind: z.enum(["event", "state"]).describe("event = an interaction; state = a fact"),
|
|
179
|
-
property: z.string().describe("e.g. 'interaction.email_sent' or 'job_title'"),
|
|
180
|
-
value: z.any().optional().describe("the event detail or the fact value; null = the fact ended"),
|
|
181
|
-
source: z.string().optional().describe("where this came from (default: agent)"),
|
|
182
|
-
})).describe("One or more observations to record"),
|
|
183
|
-
},
|
|
184
|
-
async ({ focus, observations }) => {
|
|
185
|
-
const result = await post("/v2/observations", { focus, observations });
|
|
186
|
-
const parts = [`Recorded ${result.recorded} observation${result.recorded !== 1 ? "s" : ""}.`];
|
|
187
|
-
if (result.claims_recomputed?.length) {
|
|
188
|
-
parts.push(`Facts updated: ${result.claims_recomputed.join(", ")}.`);
|
|
189
|
-
}
|
|
190
|
-
parts.push(`(entity_id: ${result.entity_id})`);
|
|
191
|
-
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
192
|
-
}
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
// ===========================================================================
|
|
196
|
-
// TOOL: query — POST /v2/query
|
|
197
|
-
// Retrieve a corpus of activity across many people. You do the analysis.
|
|
198
|
-
// ===========================================================================
|
|
199
|
-
server.tool(
|
|
200
|
-
"query",
|
|
201
|
-
"Retrieve and summarise activity across many people. Three powers:\n" +
|
|
202
|
-
" 1. return:'entities' groups results by person/company (one row per entity, ranked by " +
|
|
203
|
-
"most-recent matching activity). Use for 'hottest leads', 'who replied this week', " +
|
|
204
|
-
"'who's in evaluating stage'.\n" +
|
|
205
|
-
" 2. `without` subtracts entities — 'sent in 5d MINUS replied in 5d' = 'no-reply leads'. " +
|
|
206
|
-
"'activity in 30d MINUS activity in 5d' = 'cooled leads'.\n" +
|
|
207
|
-
" 3. rollups.by_value appears when scope.kind='state' — counts entities by current value " +
|
|
208
|
-
"(use scope.property='stage' for funnel reports).",
|
|
209
|
-
{
|
|
210
|
-
scope: z.object({
|
|
211
|
-
kind: z.enum(["event", "state"]).optional(),
|
|
212
|
-
property: z.string().optional().describe("property prefix — 'interaction.email' covers email_sent and email_replied"),
|
|
213
|
-
source: z.string().optional().describe("e.g. 'gmail', 'linkedin', 'slack'"),
|
|
214
|
-
entity_id: z.string().optional().describe("scope to one person/company"),
|
|
215
|
-
since_days: z.number().optional().describe("only activity within the last N days"),
|
|
216
|
-
limit: z.number().optional().describe("max items (default 50, cap 200)"),
|
|
217
|
-
}).describe("Corpus filter"),
|
|
218
|
-
without: z.object({
|
|
219
|
-
kind: z.enum(["event", "state"]).optional(),
|
|
220
|
-
property: z.string().optional(),
|
|
221
|
-
source: z.string().optional(),
|
|
222
|
-
entity_id: z.string().optional(),
|
|
223
|
-
since_days: z.number().optional(),
|
|
224
|
-
}).optional().describe("Subtract entities matching this scope from the result — same shape as scope. Enables 'sent but no reply', 'cooled in last N days'."),
|
|
225
|
-
return: z.enum(["observations", "entities"]).optional()
|
|
226
|
-
.describe("observations (default) = one row per observation. entities = one row per entity, ranked by most-recent matching activity."),
|
|
227
|
-
question: z.string().optional().describe("What you want to learn — echoed back; enables semantic ranking"),
|
|
228
|
-
},
|
|
229
|
-
async ({ scope, without, return: returnMode, question }) => {
|
|
230
|
-
const body = { scope, question };
|
|
231
|
-
if (without) body.without = without;
|
|
232
|
-
if (returnMode) body.return = returnMode;
|
|
233
|
-
const r = await post("/v2/query", body);
|
|
234
|
-
const head = `${r.matched} match${r.matched !== 1 ? "es" : ""}` +
|
|
235
|
-
(r.sampled ? ` (showing ${r.returned})` : "") +
|
|
236
|
-
(r.return === "entities" ? " · grouped by entity" : "");
|
|
237
|
-
const roll = Object.entries(r.rollups?.by_type ?? {})
|
|
238
|
-
.map(([t, n]) => `${n}× ${fmtType(t)}`).join(" · ");
|
|
239
|
-
const lines = [head, roll].filter(Boolean);
|
|
240
|
-
if (r.rollups?.by_value && Object.keys(r.rollups.by_value).length) {
|
|
241
|
-
lines.push("BY VALUE: " + Object.entries(r.rollups.by_value).map(([v, n]) => `${v}: ${n}`).join(", "));
|
|
242
|
-
}
|
|
243
|
-
lines.push("");
|
|
244
|
-
for (const it of r.items ?? []) {
|
|
245
|
-
if (r.return === "entities") {
|
|
246
|
-
lines.push(` ${it.entity_name ?? it.entity_id} ` +
|
|
247
|
-
`(${it.matches} match${it.matches !== 1 ? "es" : ""}, last ${relAge(it.most_recent_at)})` +
|
|
248
|
-
(it.most_recent_value != null ? ` → ${fmtVal(it.most_recent_value)}` : "") +
|
|
249
|
-
(it.most_recent_summary ? `\n ${it.most_recent_summary}` : ""));
|
|
250
|
-
} else {
|
|
251
|
-
lines.push(` ${relAge(it.when)} ${it.entity_name ?? it.entity_id} ` +
|
|
252
|
-
`${fmtType(it.type)}${it.summary ? `: ${it.summary}` : ""}`);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
256
|
-
}
|
|
257
|
-
);
|
|
258
|
-
|
|
259
|
-
// ===========================================================================
|
|
260
|
-
// TOOL: attention — GET /v2/attention
|
|
261
|
-
// What to look at: accounts gone quiet, key facts decayed.
|
|
262
|
-
// ===========================================================================
|
|
263
|
-
server.tool(
|
|
264
|
-
"attention",
|
|
265
|
-
"What needs your attention across the workspace right now — accounts that have gone quiet and " +
|
|
266
|
-
"key facts that have decayed. Returns ranked items, each with what happened and a suggested " +
|
|
267
|
-
"action. Call this to decide who to work next.",
|
|
268
|
-
{
|
|
269
|
-
limit: z.number().min(1).max(100).optional().describe("Max items (default 25)"),
|
|
270
|
-
},
|
|
271
|
-
async ({ limit }) => {
|
|
272
|
-
const r = await get("/v2/attention", limit ? { limit } : {});
|
|
273
|
-
if (!r.items?.length) {
|
|
274
|
-
return { content: [{ type: "text", text: "Nothing needs attention right now." }] };
|
|
275
|
-
}
|
|
276
|
-
const lines = r.items.map(it =>
|
|
277
|
-
` ${it.entity_name ?? it.entity_id} — ${it.what}\n → ${it.suggested_action}`);
|
|
278
|
-
return { content: [{ type: "text", text: `Needs attention (${r.items.length}):\n${lines.join("\n")}` }] };
|
|
279
|
-
}
|
|
280
|
-
);
|
|
281
|
-
|
|
282
|
-
// ===========================================================================
|
|
283
|
-
// TOOL: verify — POST /v2/verify
|
|
284
|
-
// Re-check a fact before acting on it — the calibration check.
|
|
285
|
-
// ===========================================================================
|
|
286
|
-
server.tool(
|
|
287
|
-
"verify",
|
|
288
|
-
"Re-check a specific fact before you act on it — e.g. an email or a deal stage that looks stale " +
|
|
289
|
-
"in get_context. Pass the person/company and the property name. Returns the fact re-derived from " +
|
|
290
|
-
"current evidence, and tells you whether it is still unverified.",
|
|
291
|
-
{
|
|
292
|
-
focus: z.string().describe("Email, LinkedIn URL, entity UUID, or name"),
|
|
293
|
-
property: z.string().describe("The fact to re-check — e.g. 'email', 'job_title', 'pipeline_stage'"),
|
|
294
|
-
},
|
|
295
|
-
async ({ focus, property }) => {
|
|
296
|
-
const r = await post("/v2/verify", { focus, property });
|
|
297
|
-
if (r.status === "ambiguous") {
|
|
298
|
-
const opts = (r.candidates ?? []).map(c =>
|
|
299
|
-
` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
|
|
300
|
-
return { content: [{ type: "text", text:
|
|
301
|
-
`"${focus}" matches several people. Call verify again with one of these entity ids:\n${opts}` }] };
|
|
302
|
-
}
|
|
303
|
-
const a = r.after ?? {};
|
|
304
|
-
return { content: [{ type: "text", text:
|
|
305
|
-
`${property}: ${fmtVal(a.value)} [${pct(a.confidence)} · ${a.freshness}]\n${r.note ?? ""}` }] };
|
|
306
|
-
}
|
|
307
|
-
);
|
|
308
|
-
|
|
309
|
-
// ===========================================================================
|
|
310
|
-
// TOOL: get_workspace_facts — GET /v2/workspace/facts
|
|
311
|
-
// The user's OWN playbook: ICP, market, product, pricing, competitors.
|
|
312
|
-
// Use this for any question about the user's business — NOT get_account.
|
|
313
|
-
// ===========================================================================
|
|
314
|
-
server.tool(
|
|
315
|
-
"get_workspace_facts",
|
|
316
|
-
"Get workspace-level facts the user has recorded about THEIR OWN business — ICP, target market, " +
|
|
317
|
-
"product, pricing, competitors, playbooks. These are NOT facts about people or companies; they " +
|
|
318
|
-
"are the user's own playbook. Use this for any question about the user's ICP, target buyer, " +
|
|
319
|
-
"pricing, market, or differentiators. ALWAYS prefer this over query/get_account when the " +
|
|
320
|
-
"question is about the user's business.",
|
|
321
|
-
{
|
|
322
|
-
categories: z.array(z.string()).optional()
|
|
323
|
-
.describe("Optional category filter, e.g. ['ICP'] or ['Pricing','Competitors']. Omit for all."),
|
|
324
|
-
limit: z.number().min(1).max(500).optional()
|
|
325
|
-
.describe("Max facts to return (default 50)"),
|
|
326
|
-
},
|
|
327
|
-
async ({ categories, limit }) => {
|
|
328
|
-
const params = {};
|
|
329
|
-
if (categories?.length) params.categories = categories.join(",");
|
|
330
|
-
if (limit != null) params.limit = limit;
|
|
331
|
-
const r = await get("/v2/workspace/facts", params);
|
|
332
|
-
if (!r.facts?.length) {
|
|
333
|
-
return { content: [{ type: "text", text:
|
|
334
|
-
"No workspace facts recorded yet. The user can add them in the Intelligence tab." }] };
|
|
335
|
-
}
|
|
336
|
-
const groups = {};
|
|
337
|
-
for (const f of r.facts) (groups[f.category] ??= []).push(f);
|
|
338
|
-
const lines = [];
|
|
339
|
-
for (const [cat, facts] of Object.entries(groups)) {
|
|
340
|
-
lines.push(`${cat.toUpperCase()} (${facts.length}):`);
|
|
341
|
-
for (const f of facts) lines.push(` ${f.content} [${relAge(f.recorded_at)}]`);
|
|
342
|
-
lines.push("");
|
|
343
|
-
}
|
|
344
|
-
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
345
|
-
}
|
|
346
|
-
);
|
|
347
|
-
|
|
348
|
-
// ───────────────────────────────────────────────────────────────────────────
|
|
22
|
+
const server = createServer();
|
|
349
23
|
const transport = new StdioServerTransport();
|
|
350
24
|
await server.connect(transport);
|