@loopops/mcp-server 2.8.0 → 3.0.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.
@@ -256,6 +256,63 @@ function persistRotatedRefreshToken(newToken) {
256
256
  }
257
257
  }
258
258
  }
259
+ /**
260
+ * Build a useful error message when Okta rejects our refresh token. The
261
+ * generic answer ("token may be revoked or idle-expired") is the last
262
+ * resort — first we check whether settings.json is pointing at the wrong
263
+ * tenant or client, which is a much more common cause and one the user
264
+ * cannot diagnose from the bare 400.
265
+ *
266
+ * The Loop API exposes the tenant it actually authenticates against at
267
+ * `GET /api/mcp/tenant-info` (added 2026-04-29 alongside the
268
+ * trial-2194356 → integrator-4680962 migration). If that endpoint
269
+ * disagrees with our env vars, we report exactly which key is wrong and
270
+ * the one-shot fix command. Older webapp deployments will 404 the
271
+ * endpoint — we silently fall through to the generic message.
272
+ */
273
+ async function diagnoseRefreshFailure(status, body) {
274
+ const head = `Okta refresh failed (HTTP ${status}). Detail: ${body.slice(0, 300)}`;
275
+ const generic = "Your refresh token may be revoked or idle-expired. Re-run " +
276
+ "`npx @loopops/mcp-cli@latest login` (the `@latest` pin bypasses the " +
277
+ "npx cache, which can serve a stale cli with old tenant defaults for " +
278
+ "up to 24h after a publish).";
279
+ if (!apiUrl)
280
+ return `${head}\n\n${generic}`;
281
+ try {
282
+ const apiBase = new URL(apiUrl).origin;
283
+ const tenantInfoUrl = `${apiBase}/api/mcp/tenant-info`;
284
+ const probe = await doFetch(tenantInfoUrl, { method: "GET", headers: { accept: "application/json" } }, 5_000);
285
+ if (!probe.ok)
286
+ return `${head}\n\n${generic}`;
287
+ const live = (await probe.json());
288
+ const issuerMismatch = !!live.issuer && live.issuer !== oktaIssuer;
289
+ const clientMismatch = !!live.clientId && live.clientId !== oktaClientId;
290
+ if (!issuerMismatch && !clientMismatch)
291
+ return `${head}\n\n${generic}`;
292
+ const lines = [
293
+ head,
294
+ "",
295
+ "DIAGNOSIS: your MCP config points at a tenant the Loop API does not recognise.",
296
+ ];
297
+ if (issuerMismatch) {
298
+ lines.push(` OKTA_ISSUER on disk: ${oktaIssuer}`);
299
+ lines.push(` OKTA_ISSUER expected: ${live.issuer}`);
300
+ }
301
+ if (clientMismatch) {
302
+ lines.push(` OKTA_CLIENT_ID on disk: ${oktaClientId}`);
303
+ lines.push(` OKTA_CLIENT_ID expected: ${live.clientId}`);
304
+ }
305
+ lines.push("");
306
+ lines.push("Fix: `npx @loopops/mcp-cli@latest login`. The `@latest` pin is " +
307
+ "important — without it, npx may serve a cached older cli that " +
308
+ "writes the same wrong defaults right back to disk.");
309
+ return lines.join("\n");
310
+ }
311
+ catch {
312
+ // Network blip, DNS failure, or older webapp without the endpoint.
313
+ return `${head}\n\n${generic}`;
314
+ }
315
+ }
259
316
  /**
260
317
  * Mint a fresh access token via Okta's /token endpoint. Updates the
261
318
  * in-memory cache AND (if Okta rotated the refresh token) persists the
@@ -298,7 +355,8 @@ async function refreshAccessTokenOnce() {
298
355
  }, DEFAULT_TIMEOUT_MS);
299
356
  if (!response.ok) {
300
357
  const body = await response.text().catch(() => "<unreadable>");
301
- throw new OktaRefreshError(`Okta refresh failed (HTTP ${response.status}). Your refresh token may be revoked or idle-expired. Re-run \`npx @loopops/mcp-cli login\`. Detail: ${body.slice(0, 300)}`);
358
+ const diagnosis = await diagnoseRefreshFailure(response.status, body);
359
+ throw new OktaRefreshError(diagnosis);
302
360
  }
303
361
  const tokens = (await response.json());
304
362
  cachedAccessToken = tokens.access_token;
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { registerConfigTools } from "./tools/config.js";
8
8
  import { registerEngTools } from "./tools/eng.js";
9
9
  import { registerCrmTools } from "./tools/crm.js";
10
10
  import { registerEngageTools } from "./tools/engage.js";
11
- import { registerCompanyGraphTools } from "./tools/company-graph.js";
11
+ import { registerAccountMasterTools } from "./tools/account-master.js";
12
12
  const skillsResponseSchema = z.object({
13
13
  role: z.string(),
14
14
  skills: z.array(z.string()),
@@ -41,6 +41,6 @@ registerConfigTools(server, allowedSkills);
41
41
  registerEngTools(server, allowedSkills);
42
42
  registerCrmTools(server, allowedSkills);
43
43
  registerEngageTools(server, allowedSkills);
44
- registerCompanyGraphTools(server, allowedSkills);
44
+ registerAccountMasterTools(server, allowedSkills);
45
45
  const transport = new StdioServerTransport();
46
46
  await server.connect(transport);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Account Master — MCP tool wrappers (ops+).
3
+ *
4
+ * Read-only tools (Phase 1.4):
5
+ * account_lookup — find accounts by domain / name / external IDs
6
+ * account_show — full detail for one account
7
+ * list_pending_account_matches — review queue for inbound candidates
8
+ *
9
+ * Mutating tools:
10
+ * import_accounts_csv — parse a CSV upload, write inbound candidates,
11
+ * optionally drain via the orchestrator
12
+ *
13
+ * See packages/api/src/routers/mcp.ts for the tRPC procedure
14
+ * implementations and packages/api/src/routers/mcp-schemas.ts for the
15
+ * input schemas.
16
+ */
17
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
+ export declare function registerAccountMasterTools(server: McpServer, allowed: Set<string>): void;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Account Master — MCP tool wrappers (ops+).
3
+ *
4
+ * Read-only tools (Phase 1.4):
5
+ * account_lookup — find accounts by domain / name / external IDs
6
+ * account_show — full detail for one account
7
+ * list_pending_account_matches — review queue for inbound candidates
8
+ *
9
+ * Mutating tools:
10
+ * import_accounts_csv — parse a CSV upload, write inbound candidates,
11
+ * optionally drain via the orchestrator
12
+ *
13
+ * See packages/api/src/routers/mcp.ts for the tRPC procedure
14
+ * implementations and packages/api/src/routers/mcp-schemas.ts for the
15
+ * input schemas.
16
+ */
17
+ import { z } from "zod";
18
+ import { trpcMutation, trpcQuery } from "../api-client.js";
19
+ import { safeTool } from "./_helpers.js";
20
+ export function registerAccountMasterTools(server, allowed) {
21
+ if (allowed.has("account_lookup")) {
22
+ server.tool("account_lookup", "Find Account Master accounts by domain, name, account_id, SF Account ID, or legal-entity identifier (DUNS/LEI/EIN). Provide at least one. Multiple keys combine as OR — returns the union. Use this to investigate review-queue candidates or to cross-reference an SF Account back to its CG record.", {
23
+ domain: z
24
+ .string()
25
+ .min(1)
26
+ .optional()
27
+ .describe("Domain to look up (e.g. 'acme.com'). Email addresses also accepted; the matcher extracts the domain."),
28
+ accountName: z
29
+ .string()
30
+ .min(1)
31
+ .optional()
32
+ .describe("Company name to match. Looks up by exact-after-normalization (Inc/LLC/etc. stripped, punctuation collapsed)."),
33
+ accountId: z
34
+ .string()
35
+ .uuid()
36
+ .optional()
37
+ .describe("Account Master account_id (UUID)."),
38
+ sfAccountId: z
39
+ .string()
40
+ .min(3)
41
+ .optional()
42
+ .describe("Salesforce Account record ID (15- or 18-char SF id)."),
43
+ duns: z.string().min(1).optional().describe("D&B DUNS identifier."),
44
+ lei: z.string().min(1).optional().describe("Legal Entity Identifier (LEI)."),
45
+ ein: z.string().min(1).optional().describe("US EIN."),
46
+ }, safeTool(async (input) => trpcQuery("mcp.accountLookup", input)));
47
+ }
48
+ if (allowed.has("account_show")) {
49
+ server.tool("account_show", "Show full detail for one Account Master account: identifiers, lifecycle states (enrichment / scoring / deployment), linked legal entities, resolved attributes (the golden record with provenance), latest ICP score, TAL memberships, Salesforce mapping, and recent lifecycle events. Use account_lookup first if you only have a domain or name.", {
50
+ accountId: z
51
+ .string()
52
+ .uuid()
53
+ .describe("Account Master account_id (UUID). Use account_lookup if you don't have it yet."),
54
+ }, safeTool(async ({ accountId }) => trpcQuery("mcp.accountShow", { accountId })));
55
+ }
56
+ if (allowed.has("list_pending_account_matches")) {
57
+ server.tool("list_pending_account_matches", "Show inbound candidates currently in `match_status='needs_review'` — the queue of inbound rows the matcher couldn't confidently resolve. Each entry includes the source, ingestion time, and the normalized attributes the matcher saw, so you can run `account_lookup` against them to investigate.", {
58
+ limit: z
59
+ .number()
60
+ .int()
61
+ .positive()
62
+ .max(200)
63
+ .optional()
64
+ .describe("Max candidates to return (1–200). Default: 50."),
65
+ source: z
66
+ .string()
67
+ .min(1)
68
+ .optional()
69
+ .describe("Filter to a single inbound source (e.g. 'csv_import', 'backfill'). Omit to see all."),
70
+ }, safeTool(async (input) => trpcQuery("mcp.listPendingAccountMatches", input)));
71
+ }
72
+ if (allowed.has("import_accounts_csv")) {
73
+ server.tool("import_accounts_csv", [
74
+ "Import a CSV of accounts into the Account Master. Headers are case- and",
75
+ "punctuation-tolerant — `Company Name`, `name`, `Web Site`, `Billing Country`,",
76
+ "`DUNS`, etc. all map to canonical fields. Rows without a domain or",
77
+ "(account_name + country) are skipped. Each accepted row becomes an",
78
+ "`inbound_account_candidate` with the given source; with `process: true` they",
79
+ "drain through the matcher immediately, otherwise they wait for the next",
80
+ "`account_master_match_orchestrator` cron tick. Use `dry_run: true` to preview without",
81
+ "writing — recommended on the first import from any new source.",
82
+ ].join(" "), {
83
+ csv: z
84
+ .string()
85
+ .min(1)
86
+ .describe("CSV content as text. First row must be the header. Accepts the same header aliases as the account-master-csv-import.mjs script."),
87
+ source: z
88
+ .string()
89
+ .min(1)
90
+ .max(64)
91
+ .regex(/^[a-z0-9_]+$/)
92
+ .optional()
93
+ .describe("Slug stored in `inbound_account_candidate.source` (snake_case). Defaults to 'csv_import'. Use a more specific value for traceability (e.g. 'partner_acme', 'tradeshow_2026q2')."),
94
+ dryRun: z
95
+ .boolean()
96
+ .optional()
97
+ .describe("If true, parse + normalize + summarize but write nothing. Use to preview unfamiliar CSV shapes."),
98
+ process: z
99
+ .boolean()
100
+ .optional()
101
+ .describe("If true, drain the queue via the matcher immediately after insert."),
102
+ }, safeTool(async (input) => trpcMutation("mcp.importAccountsCsv", input)));
103
+ }
104
+ if (allowed.has("show_account_master_config")) {
105
+ server.tool("show_account_master_config", [
106
+ "Read the current contents of a Account Master YAML config from the repo.",
107
+ "Use this before update_account_master_config to see the current state. Today only",
108
+ "`resolution_rules` exists; future entries (e.g. sfdc_field_map) join the enum.",
109
+ ].join(" "), {
110
+ file: z
111
+ .enum(["resolution_rules"])
112
+ .describe("Which file under config/account_master/ to read. Today only resolution_rules."),
113
+ }, safeTool(async (input) => trpcQuery("mcp.showAccountMasterConfig", input)));
114
+ }
115
+ if (allowed.has("sync_account_master_resolution_rules")) {
116
+ server.tool("sync_account_master_resolution_rules", [
117
+ "Apply the committed config/account_master/resolution_rules.yaml to the DB.",
118
+ "Server-side equivalent of `node scripts/account-master-sync-resolution-rules.mjs --resolve`.",
119
+ "Idempotent — re-running with no YAML changes is a no-op. Pair with",
120
+ "update_account_master_config to complete an edit→commit→apply cycle from one Claude",
121
+ "session, no local clone needed. Resolver backfills golden records by default.",
122
+ ].join(" "), {
123
+ branch: z
124
+ .string()
125
+ .optional()
126
+ .describe("Branch to read YAML from. Default: main. Useful for testing a feature branch's YAML before merging."),
127
+ resolve: z
128
+ .boolean()
129
+ .optional()
130
+ .describe("Re-run the resolver across affected accounts after applying rule changes so golden records update. Default: true."),
131
+ dryRun: z
132
+ .boolean()
133
+ .optional()
134
+ .describe("Read + validate + diff against DB without writing. Useful for previewing."),
135
+ }, safeTool(async (input) => trpcMutation("mcp.syncAccountMasterResolutionRules", input)));
136
+ }
137
+ if (allowed.has("update_account_master_config")) {
138
+ server.tool("update_account_master_config", [
139
+ "Edit a Account Master YAML config by committing the new content to the repo",
140
+ "via the GitHub API (same pattern as update_config for per-loop configs).",
141
+ "Workflow: read the current file with show_account_master_config, edit, send back via this tool.",
142
+ "Commit goes to the target branch (default `main`); Vercel auto-deploys.",
143
+ "After commit, run `node scripts/account-master-sync-resolution-rules.mjs --resolve`",
144
+ "from a local clone to apply the change to the DB and backfill golden records.",
145
+ "There's no direct DB-write path — YAML is the single source of truth, drift is",
146
+ "structurally impossible.",
147
+ ].join(" "), {
148
+ file: z
149
+ .enum(["resolution_rules"])
150
+ .describe("Which file under config/account_master/ to update. Today only resolution_rules."),
151
+ content: z
152
+ .string()
153
+ .min(1)
154
+ .describe("Full new YAML content (the tool replaces the file wholesale). Run show_account_master_config first to read, then edit, then send back."),
155
+ message: z
156
+ .string()
157
+ .min(3)
158
+ .max(72)
159
+ .describe("Git commit message. Convention: `cg: <what changed and why>` (e.g. 'cg: drop annual_revenue min_confidence to 40 — pdl coverage sparse')."),
160
+ branch: z
161
+ .string()
162
+ .optional()
163
+ .describe("Target branch. Default: main. Pass a feature branch if you want review-before-merge."),
164
+ }, safeTool(async (input) => trpcMutation("mcp.updateAccountMasterConfig", input)));
165
+ }
166
+ }
@@ -391,7 +391,7 @@ export function registerConfigTools(server, allowed) {
391
391
  .string()
392
392
  .min(3)
393
393
  .max(72)
394
- .describe("Git commit message. Convention: `govern: <what changed and why>` (e.g. 'govern: disable cg_sync_clickhouse cron temporarily')."),
394
+ .describe("Git commit message. Convention: `govern: <what changed and why>` (e.g. 'govern: disable account_master_sync_clickhouse cron temporarily')."),
395
395
  branch: z
396
396
  .string()
397
397
  .optional()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loopops/mcp-server",
3
- "version": "2.8.0",
3
+ "version": "3.0.0",
4
4
  "description": "Loop Operations MCP Server — AI skills for RevOps",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",