scrupp-mcp-server 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -108,10 +108,10 @@ search URL with the size Sales Navigator reports, no credits),
108
108
  `scrupp_run_apollo_search`, `scrupp_enrich_linkedin_profiles` (optionally with
109
109
  emails), `scrupp_lookup_company` and `scrupp_find_decision_makers`.
110
110
 
111
- All of them run only on the caller's own accounts connected with the Scrupp
112
- extension. The Scrupp API enforces that, not this server: with this key it
113
- hands out only the caller's own LinkedIn sessions and refuses the job when
114
- there is none.
111
+ When the caller has a LinkedIn account with Sales Navigator connected with the
112
+ Scrupp extension, all of them run only on that account. Without one, they run
113
+ on Scrupp's shared account pool, which is open to paid plans. The Scrupp API
114
+ decides this, not this server.
115
115
 
116
116
  The OAuth resource is what tells the two apart: the protected-resource metadata
117
117
  for `/sn` names `https://mcp.scrupp.com/sn`, and app.scrupp.com issues the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrupp-mcp-server",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "MCP server for Scrupp \u2014 export LinkedIn, Sales Navigator and Apollo searches, find decision makers, and find and verify work emails",
5
5
  "keywords": [
6
6
  "mcp",
@@ -20,6 +20,7 @@
20
20
  },
21
21
  "type": "module",
22
22
  "bin": {
23
+ "scrupp-mcp-server": "src/index.js",
23
24
  "scrupp-mcp": "src/index.js",
24
25
  "scrupp-mcp-http": "src/http.js"
25
26
  },
package/server.json CHANGED
@@ -6,14 +6,14 @@
6
6
  "url": "https://github.com/Wism-Corp/scrupp-mcp-server",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.2.0",
9
+ "version": "0.2.1",
10
10
  "websiteUrl": "https://scrupp.com/docs/api/integrations-jobs",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "scrupp-mcp-server",
16
- "version": "0.2.0",
16
+ "version": "0.2.1",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  },
package/src/client.js CHANGED
@@ -18,7 +18,7 @@ class ScruppError extends Error {
18
18
  }
19
19
  }
20
20
 
21
- async function call(path, { method = "GET", body, headers } = {}) {
21
+ async function call(path, { method = "GET", body, headers, notReadyOk = false } = {}) {
22
22
  const apiKey = requestKey.getStore() ?? process.env.SCRUPP_API_KEY;
23
23
  if (!apiKey) {
24
24
  throw new ScruppError("No Scrupp API key: set SCRUPP_API_KEY or send Authorization: Bearer.", "invalid_api_key");
@@ -37,7 +37,10 @@ async function call(path, { method = "GET", body, headers } = {}) {
37
37
  const payload = await response.json().catch(() => ({}));
38
38
 
39
39
  // A 409 on the result endpoint means "not finished yet" — the caller decides.
40
- if (!response.ok && response.status !== 409) {
40
+ // Anywhere else a 409 is a refusal (e.g. an Idempotency-Key reused with a
41
+ // different body on POST /jobs); letting it through as success made the
42
+ // caller poll /jobs/undefined and show the connector guard's 403 instead.
43
+ if (!response.ok && !(notReadyOk && response.status === 409)) {
41
44
  throw new ScruppError(
42
45
  errorText(payload.message ?? payload.error, `Scrupp returned ${response.status}`),
43
46
  payload.error_code ?? payload.error?.code,
@@ -101,7 +104,7 @@ export async function jobStatus(jobId) {
101
104
  }
102
105
 
103
106
  export async function jobResult(jobId) {
104
- const { status, payload } = await call(`/jobs/${jobId}/result`);
107
+ const { status, payload } = await call(`/jobs/${jobId}/result`, { notReadyOk: true });
105
108
  return { ready: status === 200, payload };
106
109
  }
107
110
 
package/src/server.js CHANGED
@@ -1,7 +1,21 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { z } from "zod";
3
4
  import { buildSearch, credits, jobResult, jobStatus, runJob, ScruppError, verifyEmail } from "./client.js";
4
5
 
6
+ /**
7
+ * Idempotency-Key for one tool call: a hash of the whole input, not its first
8
+ * 200 characters. Two Sales Navigator URLs share a long prefix, so the
9
+ * truncated form gave two different searches the same key and Scrupp refused
10
+ * the second with 409 idempotency_key_reused. The ten-minute bucket keeps
11
+ * client retries of one call deduplicated without replaying an old job when
12
+ * the person deliberately runs the same search again later.
13
+ */
14
+ export function idempotencyKeyFor(jobType, input, now = Date.now()) {
15
+ const digest = createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 32);
16
+ return `mcp-${jobType}-${digest}-${Math.floor(now / 600_000)}`;
17
+ }
18
+
5
19
  const DEFAULT_WAIT_SECONDS = Number(process.env.SCRUPP_WAIT_SECONDS ?? 120);
6
20
 
7
21
  /**
@@ -19,8 +33,9 @@ const SPENDS = { readOnlyHint: false, destructiveHint: false, idempotentHint: tr
19
33
  * email finding and verification plus the bookkeeping tools. `"sn"` is the
20
34
  * private connector (mcp.scrupp.com/sn): core plus the rest of the API —
21
35
  * Sales Navigator search built from words, LinkedIn and Apollo searches,
22
- * profiles, companies, decision makers — which the Scrupp API runs only on the
23
- * caller's own connected accounts.
36
+ * profiles, companies, decision makers — which the Scrupp API runs on the
37
+ * caller's own LinkedIn account with Sales Navigator when one is connected, and
38
+ * otherwise on Scrupp's shared account pool (paid plans).
24
39
  */
25
40
  export function createServer({ toolset = "full" } = {}) {
26
41
  // Claude reads `instructions` when the server connects: the one place to point
@@ -85,7 +100,7 @@ export function createServer({ toolset = "full" } = {}) {
85
100
  const result = await runJob({
86
101
  type: jobType,
87
102
  input: toInput(args),
88
- idempotencyKey: `mcp-${jobType}-${JSON.stringify(toInput(args))}`.slice(0, 200),
103
+ idempotencyKey: idempotencyKeyFor(jobType, toInput(args)),
89
104
  waitSeconds: args.wait_seconds ?? DEFAULT_WAIT_SECONDS,
90
105
  });
91
106
  return json(result);
@@ -124,7 +139,7 @@ export function createServer({ toolset = "full" } = {}) {
124
139
  description:
125
140
  "Turn a plain-words audience (role, industry, country or cities, company size) into a Sales Navigator search URL, " +
126
141
  "and report how many people Sales Navigator says it matches. Costs no credits. Runs on the user's own connected " +
127
- "Sales Navigator account. Pass the returned `url` to scrupp_export_sales_navigator_search. If the size is far " +
142
+ "Sales Navigator account when there is one, otherwise on Scrupp's shared account pool (paid plans). Pass the returned `url` to scrupp_export_sales_navigator_search. If the size is far " +
128
143
  "from what the user wants, rephrase the audience and build again before exporting.",
129
144
  inputSchema: {
130
145
  audience: z
@@ -147,8 +162,8 @@ export function createServer({ toolset = "full" } = {}) {
147
162
  title: "Export a Sales Navigator search",
148
163
  description:
149
164
  "Extract the people from a LinkedIn Sales Navigator search URL (linkedin.com/sales/search/people), with titles, " +
150
- "companies and, by default, verified work emails. Runs only on the user's own Sales Navigator account connected " +
151
- "with the Scrupp extension. One credit per record; check scrupp_credits first and start with a small `max` " +
165
+ "companies and, by default, verified work emails. Runs on the user's own Sales Navigator account connected " +
166
+ "with the Scrupp extension when there is one, otherwise on Scrupp's shared account pool (paid plans). One credit per record; check scrupp_credits first and start with a small `max` " +
152
167
  "to confirm the list looks right before exporting the whole search.",
153
168
  schema: {
154
169
  ...searchSchema,
@@ -174,11 +189,12 @@ export function createServer({ toolset = "full" } = {}) {
174
189
  });
175
190
  }
176
191
 
177
- // The rest of the API. On the private connector every one of these runs on the
178
- // user's own connected accounts only: the Scrupp API holds the connector's key
179
- // to the caller's sessions and refuses rather than borrowing anyone else's.
192
+ // The rest of the API. On the private connector the Scrupp API decides whose
193
+ // session runs each job: the caller's own LinkedIn account with Sales
194
+ // Navigator when one is connected, otherwise the shared account pool, which
195
+ // is open to paid plans only.
180
196
  const ownAccounts = sn
181
- ? " Runs only on the user's own accounts connected with the Scrupp extension; without one it is refused, never run on someone else's."
197
+ ? " Runs on the user's own LinkedIn account with Sales Navigator when one is connected with the Scrupp extension; otherwise on Scrupp's shared account pool (paid plans)."
182
198
  : "";
183
199
 
184
200
  if (full || sn) {