@opennous/mcp 0.11.1 → 0.18.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 +7 -1
- package/src/client.js +25 -4
- package/src/index.js +9 -1
- package/src/server.js +277 -5
package/package.json
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opennous/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Nous MCP Server — Customer graph for GTM agents.",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/NousC/nous.git",
|
|
9
|
+
"directory": "apps/mcp"
|
|
10
|
+
},
|
|
5
11
|
"type": "module",
|
|
6
12
|
"bin": {
|
|
7
13
|
"nous-mcp": "src/index.js"
|
package/src/client.js
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
12
15
|
|
|
13
16
|
// Resolve an env var defensively — Claude Code plugins use ${user_config.X}
|
|
14
17
|
// substitution; when an optional userConfig field is left blank, the literal
|
|
@@ -32,15 +35,33 @@ export function runWithApiKey(apiKey, fn) {
|
|
|
32
35
|
return apiKeyStore.run({ apiKey }, fn);
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
// Credential written by `nous login` (the browser device-auth flow). The CLI and
|
|
39
|
+
// the MCP server share ~/.nous/config.json, so a user who runs the login command
|
|
40
|
+
// gets a key the MCP picks up on the next call — no paste, no env var.
|
|
41
|
+
function fileApiKey() {
|
|
42
|
+
try {
|
|
43
|
+
const dir = resolvedEnv("NOUS_CONFIG_DIR") || path.join(os.homedir(), ".nous");
|
|
44
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(dir, "config.json"), "utf8"));
|
|
45
|
+
const k = cfg?.apiKey;
|
|
46
|
+
return k && !String(k).includes("${") ? k : undefined;
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
function currentApiKey() {
|
|
36
|
-
return apiKeyStore.getStore()?.apiKey ?? resolvedEnv("NOUS_API_KEY");
|
|
53
|
+
return apiKeyStore.getStore()?.apiKey ?? resolvedEnv("NOUS_API_KEY") ?? fileApiKey();
|
|
37
54
|
}
|
|
38
55
|
|
|
39
|
-
// stdio-only preflight
|
|
56
|
+
// stdio-only preflight. A key may come from the env OR from `nous login`'s
|
|
57
|
+
// credential file. This is advisory — the server still starts without one so
|
|
58
|
+
// the user can run the login command after installing the plugin, and the key
|
|
59
|
+
// is resolved per-call.
|
|
40
60
|
export function validateConfig() {
|
|
41
|
-
if (!resolvedEnv("NOUS_API_KEY")) {
|
|
61
|
+
if (!resolvedEnv("NOUS_API_KEY") && !fileApiKey()) {
|
|
42
62
|
throw new Error(
|
|
43
|
-
"
|
|
63
|
+
"No Nous API key found. Run the /nous-login command (or `npx @opennous/cli login`) to sign in, " +
|
|
64
|
+
"or set NOUS_API_KEY."
|
|
44
65
|
);
|
|
45
66
|
}
|
|
46
67
|
}
|
package/src/index.js
CHANGED
|
@@ -17,7 +17,15 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
17
17
|
import { validateConfig } from "./client.js";
|
|
18
18
|
import { createServer } from "./server.js";
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// Advisory only — don't hard-exit if there's no key yet. The user may install
|
|
21
|
+
// the plugin and then run /nous-login; the server must already be running so the
|
|
22
|
+
// key (resolved per-call from env or ~/.nous/config.json) is picked up without a
|
|
23
|
+
// restart.
|
|
24
|
+
try {
|
|
25
|
+
validateConfig();
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error(`[nous] ${err.message}`);
|
|
28
|
+
}
|
|
21
29
|
|
|
22
30
|
const server = createServer();
|
|
23
31
|
const transport = new StdioServerTransport();
|
package/src/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Nous MCP server factory.
|
|
3
3
|
*
|
|
4
|
-
* Builds an McpServer with the
|
|
4
|
+
* Builds an McpServer with the v2 tools registered. Both entrypoints use it:
|
|
5
5
|
* - index.js (stdio bin, published as @opennous/mcp) — one server, env-scoped key
|
|
6
6
|
* - http.js (hosted, mcp.opennous.cloud) — a fresh server per request,
|
|
7
7
|
* key scoped via AsyncLocalStorage
|
|
@@ -18,13 +18,19 @@
|
|
|
18
18
|
* attention — what needs your attention (accounts gone quiet, facts decayed)
|
|
19
19
|
* verify — re-check a fact before acting on it
|
|
20
20
|
* get_gtm_profile — the user's GTM profile (ICP, market, pricing, product, competitors)
|
|
21
|
+
* update_gtm_profile — write back a change to a GTM context section (evolve, keep history)
|
|
22
|
+
* save_note — attach a note/document (meeting brief, transcript, prep) to a contact
|
|
23
|
+
* search_notes — semantic search over saved notes & documents
|
|
24
|
+
* get_workspace_status — what's set up in this workspace + a ranked next_steps list (call first)
|
|
25
|
+
* set_workspace_profile— agent-driven onboarding: set the workspace's name, site, type, ICP
|
|
26
|
+
* build_scoring_model — build/rebuild the ICP scoring model from the recorded GTM context
|
|
21
27
|
*/
|
|
22
28
|
|
|
23
29
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
24
30
|
import { z } from "zod";
|
|
25
31
|
import { get, post } from "./client.js";
|
|
26
32
|
|
|
27
|
-
export const SERVER_VERSION = "0.
|
|
33
|
+
export const SERVER_VERSION = "0.18.0";
|
|
28
34
|
|
|
29
35
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
30
36
|
|
|
@@ -50,8 +56,10 @@ export function createServer() {
|
|
|
50
56
|
name: "nous",
|
|
51
57
|
version: SERVER_VERSION,
|
|
52
58
|
description:
|
|
53
|
-
"Nous — the context layer for GTM agents.
|
|
54
|
-
"
|
|
59
|
+
"Nous — the context layer for GTM agents. Nous is operated by the agent, not by a human " +
|
|
60
|
+
"clicking around: call get_workspace_status at the start of a session to see what's set up " +
|
|
61
|
+
"and what to set up next. Call get_context before drafting outreach or preparing for a " +
|
|
62
|
+
"meeting. Call record after every interaction, or whenever you learn something.",
|
|
55
63
|
icons: [
|
|
56
64
|
{ src: "https://opennous.cloud/newlogoP.png", mimeType: "image/png", sizes: ["64x64"] },
|
|
57
65
|
],
|
|
@@ -115,6 +123,17 @@ export function createServer() {
|
|
|
115
123
|
}
|
|
116
124
|
lines.push("");
|
|
117
125
|
}
|
|
126
|
+
if (ctx.documents?.length) {
|
|
127
|
+
// Meeting briefs / notes / transcripts kept on the contact — an overview
|
|
128
|
+
// (snippets only). To pull relevant content, use search_notes (semantic).
|
|
129
|
+
lines.push("DOCUMENTS (notes & meeting records — use search_notes to search their content):");
|
|
130
|
+
for (const d of ctx.documents) {
|
|
131
|
+
const when = d.date ? ` [${relAge(d.date)}]` : "";
|
|
132
|
+
lines.push(` ${d.type.replace(/_/g, " ")}${d.title ? ` · ${d.title}` : ""}${when}`);
|
|
133
|
+
if (d.snippet) lines.push(` ${d.snippet}`);
|
|
134
|
+
}
|
|
135
|
+
lines.push("");
|
|
136
|
+
}
|
|
118
137
|
if (ctx.stakeholders?.length) {
|
|
119
138
|
lines.push("STAKEHOLDERS:");
|
|
120
139
|
for (const s of ctx.stakeholders) lines.push(` ${s.name ?? "—"} — ${s.role ?? ""}`);
|
|
@@ -349,7 +368,17 @@ export function createServer() {
|
|
|
349
368
|
const lines = [];
|
|
350
369
|
for (const [cat, facts] of Object.entries(groups)) {
|
|
351
370
|
lines.push(`${cat.toUpperCase()} (${facts.length}):`);
|
|
352
|
-
for (const f of facts)
|
|
371
|
+
for (const f of facts) {
|
|
372
|
+
// Flag AI-drafted facts (confidence < 1) and ones not confirmed in a long
|
|
373
|
+
// time, so the agent treats them as provisional and prefers fresh,
|
|
374
|
+
// user-confirmed facts when they conflict.
|
|
375
|
+
const ageDays = f.recorded_at ? Math.floor((Date.now() - new Date(f.recorded_at).getTime()) / 86400000) : 0;
|
|
376
|
+
const tags = [];
|
|
377
|
+
if (typeof f.confidence === "number" && f.confidence < 1) tags.push("inferred");
|
|
378
|
+
if (ageDays >= 90) tags.push("stale");
|
|
379
|
+
const tag = tags.length ? ` (${tags.join(", ")})` : "";
|
|
380
|
+
lines.push(` ${f.content}${tag} [${relAge(f.recorded_at)}]`);
|
|
381
|
+
}
|
|
353
382
|
lines.push("");
|
|
354
383
|
}
|
|
355
384
|
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
@@ -358,5 +387,248 @@ export function createServer() {
|
|
|
358
387
|
// Legacy alias — keeps existing integrations calling get_workspace_facts working.
|
|
359
388
|
server.tool("get_workspace_facts", gtmProfileDescription, gtmProfileSchema, gtmProfileHandler);
|
|
360
389
|
|
|
390
|
+
// ===========================================================================
|
|
391
|
+
// TOOL: update_gtm_profile — POST /v2/workspace/facts
|
|
392
|
+
// Write-back: the agent records a durable change to the user's OWN GTM profile
|
|
393
|
+
// and EVOLVES the matching belief (supersede + keep history) instead of piling
|
|
394
|
+
// up contradictions. This is the loop that keeps the context current as the
|
|
395
|
+
// company learns — pair it with get_gtm_profile.
|
|
396
|
+
// ===========================================================================
|
|
397
|
+
server.tool(
|
|
398
|
+
"update_gtm_profile",
|
|
399
|
+
"Keep a SECTION of the user's OWN GTM context current. Each section is a living file: ICP, " +
|
|
400
|
+
"Market, Product, Pricing, Competitors, Positioning (these feed the ICP scoring model), plus " +
|
|
401
|
+
"'GTM Motion' (how they sell — motion, RevOps, process) and 'Notes' (a running log for anything " +
|
|
402
|
+
"else durable about their GTM that doesn't fit the others). Use this whenever the user states or " +
|
|
403
|
+
"you learn a lasting change to how THEY go to market — repriced, moved upmarket, sharpened " +
|
|
404
|
+
"positioning, changed their motion, won a new segment, or a useful note about how they operate. " +
|
|
405
|
+
"This is NOT for facts about a prospect or account (use `record` for those). " +
|
|
406
|
+
"Rules: keep content short and current — a sentence or two, not an essay. In the default 'replace' " +
|
|
407
|
+
"mode the section EVOLVES (the old version is kept as history, never silently contradicted), so " +
|
|
408
|
+
"just write the section's current state. Use 'append' mode to log a Notes entry without replacing. " +
|
|
409
|
+
"Nous is the source of truth for the GTM context — write back here instead of keeping a local file.",
|
|
410
|
+
{
|
|
411
|
+
section: z.enum(["ICP", "Market", "Product", "Pricing", "Competitors", "Positioning", "GTM Motion", "Notes"])
|
|
412
|
+
.describe("Which section of the GTM context this updates."),
|
|
413
|
+
content: z.string().describe("The section's current content — short and current, not an essay."),
|
|
414
|
+
mode: z.enum(["replace", "append"]).optional()
|
|
415
|
+
.describe("'replace' (default) evolves the section and keeps the prior version as history. 'append' logs a new entry without replacing — the default for Notes."),
|
|
416
|
+
supersedes: z.string().optional()
|
|
417
|
+
.describe("Optional id of a specific existing fact to replace (overrides section matching)."),
|
|
418
|
+
},
|
|
419
|
+
async ({ section, content, mode, supersedes }) => {
|
|
420
|
+
const r = await post("/v2/workspace/facts", { section, content, mode, supersedes });
|
|
421
|
+
const verb = r.mode === "append" ? "Logged to" : r.superseded ? "Updated" : "Recorded";
|
|
422
|
+
return { content: [{ type: "text", text: `${verb} ${section}: ${content}` }] };
|
|
423
|
+
},
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
// ===========================================================================
|
|
427
|
+
// TOOL: save_note — POST /v2/notes
|
|
428
|
+
// Attach a long-form artifact to a CONTACT: a meeting brief you wrote, a
|
|
429
|
+
// transcript, pre-meeting prep, or a plain note. Append-only and dated, so the
|
|
430
|
+
// contact builds a record across meetings. Distinct from `record` (which logs
|
|
431
|
+
// that an interaction happened) — this keeps the document itself.
|
|
432
|
+
// ===========================================================================
|
|
433
|
+
server.tool(
|
|
434
|
+
"save_note",
|
|
435
|
+
"Save a note or document onto a person or company so it is kept on their record — a meeting " +
|
|
436
|
+
"brief you wrote, a transcript, pre-meeting prep, research, or a plain note. Use this whenever " +
|
|
437
|
+
"you produce something durable about a specific contact that's worth keeping for next time (e.g. " +
|
|
438
|
+
"after writing a meeting brief, save it to the contact so future meetings can reference it). " +
|
|
439
|
+
"Notes are append-only and dated, so a contact builds a record across meetings — later you can " +
|
|
440
|
+
"read the last few and see what changed. This is NOT for logging that an interaction happened " +
|
|
441
|
+
"(use `record` with an interaction.* event for that), and NOT for the user's own GTM profile " +
|
|
442
|
+
"(use `update_gtm_profile`). Put the full text in `content` — it's kept for agents to read; the " +
|
|
443
|
+
"UI shows the title and date, not the whole body.",
|
|
444
|
+
{
|
|
445
|
+
focus: z.string().describe("Who to attach it to — an email, LinkedIn URL, domain, or entity UUID (not a bare name)."),
|
|
446
|
+
content: z.string().describe("The full note or document text (a short note or a complete brief/transcript)."),
|
|
447
|
+
type: z.enum(["note", "meeting_brief", "transcript", "meeting_notes", "pre_meeting", "research"])
|
|
448
|
+
.optional().describe("What kind of document this is (default: note)."),
|
|
449
|
+
title: z.string().optional().describe("A short name, e.g. 'Pre-meeting brief — renewal' or 'Transcript — Jun 1'."),
|
|
450
|
+
date: z.string().optional().describe("The relevant date (e.g. the meeting date, ISO or plain). Defaults to now."),
|
|
451
|
+
},
|
|
452
|
+
async ({ focus, content, type, title, date }) => {
|
|
453
|
+
const r = await post("/v2/notes", { focus, content, type, title, date });
|
|
454
|
+
const label = title || (r.doc_type || "note").replace(/_/g, " ");
|
|
455
|
+
return { content: [{ type: "text", text: `Saved ${label} to ${focus}.` }] };
|
|
456
|
+
},
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
// ===========================================================================
|
|
460
|
+
// TOOL: search_notes — POST /v2/notes/search
|
|
461
|
+
// Semantic search over saved notes & documents (briefs, transcripts, notes).
|
|
462
|
+
// The retrieval counterpart to save_note — pull relevant document content
|
|
463
|
+
// instead of dumping whole documents into context.
|
|
464
|
+
// ===========================================================================
|
|
465
|
+
server.tool(
|
|
466
|
+
"search_notes",
|
|
467
|
+
"Semantically search the saved notes & documents (meeting briefs, transcripts, meeting notes) " +
|
|
468
|
+
"kept on contacts. Use this to pull relevant content from the record — e.g. 'what did we discuss " +
|
|
469
|
+
"about pricing', 'objections raised in past meetings', or to compare across a contact's meetings. " +
|
|
470
|
+
"Pass `focus` to restrict to one person/company, or omit it to search across everyone. Returns the " +
|
|
471
|
+
"matching documents (type, title, date, similarity, snippet); get the full body with get_account.",
|
|
472
|
+
{
|
|
473
|
+
question: z.string().describe("Natural-language query to match against document content."),
|
|
474
|
+
focus: z.string().optional().describe("Optional — restrict to one person/company (email, LinkedIn URL, domain, or entity UUID)."),
|
|
475
|
+
limit: z.number().optional().describe("Max documents to return (default 8)."),
|
|
476
|
+
},
|
|
477
|
+
async ({ question, focus, limit }) => {
|
|
478
|
+
const r = await post("/v2/notes/search", { question, focus, limit });
|
|
479
|
+
if (!r.documents?.length) {
|
|
480
|
+
return { content: [{ type: "text", text: `No saved documents matched "${question}".` }] };
|
|
481
|
+
}
|
|
482
|
+
const lines = [`Documents matching "${question}":`, ""];
|
|
483
|
+
for (const d of r.documents) {
|
|
484
|
+
const when = d.date ? ` [${relAge(d.date)}]` : "";
|
|
485
|
+
lines.push(` ${d.type.replace(/_/g, " ")}${d.title ? ` · ${d.title}` : ""} (${pct(d.similarity)})${when}`);
|
|
486
|
+
if (d.snippet) lines.push(` ${d.snippet}`);
|
|
487
|
+
lines.push(` (entity_id: ${d.entity_id})`);
|
|
488
|
+
}
|
|
489
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
490
|
+
},
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
// ===========================================================================
|
|
494
|
+
// TOOL: get_workspace_status — GET /v2/workspace/status
|
|
495
|
+
// The "one main call." Nous is operated by the agent, so the agent needs to
|
|
496
|
+
// know the state of the workspace: is it onboarded, is the GTM playbook built,
|
|
497
|
+
// which integrations are connected, is CRM sync configured, are events live —
|
|
498
|
+
// and what to set up next. Call this at the start of a session.
|
|
499
|
+
// ===========================================================================
|
|
500
|
+
server.tool(
|
|
501
|
+
"get_workspace_status",
|
|
502
|
+
"See the whole setup state of this workspace in one call — and what to set up next. Nous is " +
|
|
503
|
+
"operated by you, the agent, not by a human clicking through the app: call this at the start of " +
|
|
504
|
+
"a session to learn whether the workspace is onboarded, whether the GTM playbook (ICP model) is " +
|
|
505
|
+
"built, which integrations are connected, whether CRM sync is configured, and whether webhooks/" +
|
|
506
|
+
"triggers are live. Returns a ranked NEXT STEPS list — walk the user through whatever is missing " +
|
|
507
|
+
"(onboard them with set_workspace_profile, build the playbook with update_gtm_profile, connect " +
|
|
508
|
+
"their tools). Use this before offering to set anything up.",
|
|
509
|
+
{},
|
|
510
|
+
async () => {
|
|
511
|
+
const s = await get("/v2/workspace/status");
|
|
512
|
+
const setup = s.setup ?? {};
|
|
513
|
+
const lines = [];
|
|
514
|
+
|
|
515
|
+
const ws = s.workspace ?? {};
|
|
516
|
+
lines.push(`WORKSPACE: ${ws.name || "(unnamed)"}${ws.website ? ` · ${ws.website}` : ""}${ws.business_type ? ` · ${ws.business_type}` : ""}`);
|
|
517
|
+
lines.push("");
|
|
518
|
+
|
|
519
|
+
const mark = (b) => (b ? "✓" : "✗");
|
|
520
|
+
lines.push("SETUP:");
|
|
521
|
+
lines.push(` ${mark(setup.onboarding?.done)} Onboarding${setup.onboarding?.done ? "" : ` — missing ${(setup.onboarding?.missing ?? []).join(", ") || "details"}`}`);
|
|
522
|
+
lines.push(` ${mark(setup.gtm_playbook?.done)} GTM playbook${setup.gtm_playbook?.model ? " (scoring model live)" : ""}${setup.gtm_playbook?.stale_facts ? ` · ${setup.gtm_playbook.stale_facts} stale fact(s)` : ""}`);
|
|
523
|
+
const ints = setup.integrations?.connected ?? [];
|
|
524
|
+
lines.push(` ${mark((setup.integrations?.count ?? 0) > 0)} Integrations (${setup.integrations?.count ?? 0})${ints.length ? `: ${ints.map((i) => i.name).join(", ")}` : ""}`);
|
|
525
|
+
const crm = setup.crm_sync ?? {};
|
|
526
|
+
lines.push(` ${mark(crm.configured)} CRM sync${crm.configured ? `: ${(crm.providers ?? []).map((p) => p.provider).join(", ")}` : ""}${crm.pending_hygiene_proposals ? ` · ${crm.pending_hygiene_proposals} hygiene proposal(s) to review` : ""}`);
|
|
527
|
+
lines.push(` ${mark(setup.enrichment?.connected)} Enrichment${setup.enrichment?.provider ? `: ${setup.enrichment.provider}` : ""}`);
|
|
528
|
+
lines.push(` ${mark((setup.webhooks?.count ?? 0) > 0 || (setup.triggers?.count ?? 0) > 0)} Events — ${setup.webhooks?.count ?? 0} webhook(s), ${setup.triggers?.count ?? 0} trigger(s)`);
|
|
529
|
+
|
|
530
|
+
if (s.next_steps?.length) {
|
|
531
|
+
lines.push("");
|
|
532
|
+
lines.push("NEXT STEPS:");
|
|
533
|
+
for (const step of s.next_steps) {
|
|
534
|
+
lines.push(` • ${step.title}`);
|
|
535
|
+
if (step.why) lines.push(` why: ${step.why}`);
|
|
536
|
+
if (step.how) lines.push(` how: ${step.how}`);
|
|
537
|
+
}
|
|
538
|
+
} else {
|
|
539
|
+
lines.push("");
|
|
540
|
+
lines.push("Everything's set up. Nothing pending.");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
544
|
+
}
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
// ===========================================================================
|
|
548
|
+
// TOOL: set_workspace_profile — POST /v2/workspace/onboarding
|
|
549
|
+
// Agent-driven onboarding. Instead of a human clicking through a wizard in the
|
|
550
|
+
// app, you collect the basics from the user in conversation and write them
|
|
551
|
+
// here. This is the first thing get_workspace_status asks for when a workspace
|
|
552
|
+
// is new.
|
|
553
|
+
// ===========================================================================
|
|
554
|
+
server.tool(
|
|
555
|
+
"set_workspace_profile",
|
|
556
|
+
"Onboard the workspace, or update its basic profile. Nous is set up by you, the agent, in " +
|
|
557
|
+
"conversation — not by the user clicking through a wizard. Ask the user for their company name, " +
|
|
558
|
+
"their website, whether they sell a SERVICE or SOFTWARE, and a sentence describing their ideal " +
|
|
559
|
+
"customer, then write them here. This seeds the GTM context and the ICP scoring model. Call " +
|
|
560
|
+
"get_workspace_status first to see what's already set; send only the fields you're setting or " +
|
|
561
|
+
"changing. After this, the next step is usually the GTM playbook (update_gtm_profile).",
|
|
562
|
+
{
|
|
563
|
+
name: z.string().optional().describe("The user's company / workspace name."),
|
|
564
|
+
website: z.string().optional().describe("The company website (used to seed the GTM context)."),
|
|
565
|
+
business_type: z.enum(["service", "software"]).optional()
|
|
566
|
+
.describe("Whether they sell a service or software — sets the CRM's buyer terminology and default signup stage."),
|
|
567
|
+
plan_model: z.enum(["free_plan", "free_trial", "both", "paid_only"]).optional()
|
|
568
|
+
.describe("For software only: how they package (free plan, free trial, both, or paid only)."),
|
|
569
|
+
default_signup_stage: z.string().optional()
|
|
570
|
+
.describe("The pipeline stage a brand-new signup lands in (e.g. 'Lead', 'Free User'). Defaults sensibly from business_type."),
|
|
571
|
+
icp: z.string().optional()
|
|
572
|
+
.describe("A sentence or two describing their ideal customer — seeds the ICP scoring model."),
|
|
573
|
+
},
|
|
574
|
+
async ({ name, website, business_type, plan_model, default_signup_stage, icp }) => {
|
|
575
|
+
const r = await post("/v2/workspace/onboarding", { name, website, business_type, plan_model, default_signup_stage, icp });
|
|
576
|
+
const w = r.workspace ?? {};
|
|
577
|
+
const set = [
|
|
578
|
+
w.name && `name=${w.name}`,
|
|
579
|
+
w.website && `site=${w.website}`,
|
|
580
|
+
w.business_type && `type=${w.business_type}`,
|
|
581
|
+
icp && "ICP recorded",
|
|
582
|
+
].filter(Boolean);
|
|
583
|
+
return { content: [{ type: "text", text:
|
|
584
|
+
`Workspace profile saved.${set.length ? ` ${set.join(" · ")}.` : ""}\n` +
|
|
585
|
+
`Next: call get_workspace_status to see what to set up next (usually the GTM playbook).` }] };
|
|
586
|
+
}
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
// ===========================================================================
|
|
590
|
+
// TOOL: build_scoring_model — POST /v2/workspace/scoring-model
|
|
591
|
+
// The second half of building the GTM playbook. The agent records the GTM
|
|
592
|
+
// context with update_gtm_profile, then calls this to turn it into a weighted
|
|
593
|
+
// ICP scoring model. After this, accounts get scored for fit and
|
|
594
|
+
// get_workspace_status shows the playbook as done.
|
|
595
|
+
// ===========================================================================
|
|
596
|
+
server.tool(
|
|
597
|
+
"build_scoring_model",
|
|
598
|
+
"Build (or rebuild) the user's ICP scoring model from the GTM context they've recorded. This is " +
|
|
599
|
+
"the second half of setting up the GTM playbook: first record the ICP and how they sell with " +
|
|
600
|
+
"update_gtm_profile, then call this to translate that context into a weighted set of scoring " +
|
|
601
|
+
"signals so accounts get scored for fit. If a model already exists it is left alone unless you " +
|
|
602
|
+
"pass force:true (use that when the GTM context has changed and the model should be rebuilt). If " +
|
|
603
|
+
"it reports no GTM context yet, record some with update_gtm_profile first, then call this again.",
|
|
604
|
+
{
|
|
605
|
+
force: z.boolean().optional()
|
|
606
|
+
.describe("Rebuild the model even if one already exists — use when the GTM context has changed."),
|
|
607
|
+
},
|
|
608
|
+
async ({ force }) => {
|
|
609
|
+
try {
|
|
610
|
+
const r = await post("/v2/workspace/scoring-model", { force: force === true });
|
|
611
|
+
const signals = r.signals ?? [];
|
|
612
|
+
const lines = [`Built the ICP scoring model — ${signals.length} signal${signals.length === 1 ? "" : "s"}:`];
|
|
613
|
+
for (const s of signals) lines.push(` • ${s.label ?? s.key} (weight ${s.weight})`);
|
|
614
|
+
lines.push("", "Accounts will now be scored for fit. Check it on the GTM Context page.");
|
|
615
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
616
|
+
} catch (e) {
|
|
617
|
+
// Surface the actionable cases (no context yet / model already exists) as
|
|
618
|
+
// guidance rather than a raw error, so the agent knows what to do next.
|
|
619
|
+
const msg = String(e?.message ?? e);
|
|
620
|
+
if (msg.includes("no_gtm_context")) {
|
|
621
|
+
return { content: [{ type: "text", text:
|
|
622
|
+
"No GTM context recorded yet. Record the ICP and how they sell with update_gtm_profile first, then build the model." }] };
|
|
623
|
+
}
|
|
624
|
+
if (msg.includes("model_exists")) {
|
|
625
|
+
return { content: [{ type: "text", text:
|
|
626
|
+
"A scoring model already exists. Call build_scoring_model again with force:true to rebuild it from the current GTM context." }] };
|
|
627
|
+
}
|
|
628
|
+
throw e;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
);
|
|
632
|
+
|
|
361
633
|
return server;
|
|
362
634
|
}
|