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 +4 -4
- package/package.json +2 -1
- package/server.json +2 -2
- package/src/client.js +6 -3
- package/src/server.js +26 -10
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
|
-
|
|
112
|
-
extension
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
23
|
-
* caller's own connected
|
|
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:
|
|
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
|
|
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
|
|
178
|
-
//
|
|
179
|
-
//
|
|
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
|
|
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) {
|