scrupp-mcp-server 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wism Corp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,170 @@
1
- # Temporary Holding Version
1
+ # Scrupp MCP server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An MCP server over the Scrupp Jobs API, so an agent can pull leads without anyone
4
+ writing HTTP calls. Ships as `scrupp-mcp-server` on npm and as a listing in the MCP
5
+ registry; that registry is what a growing number of agent runtimes read when they pick
6
+ a tool, which is why this is the channel where the buyer is not a person.
7
+
8
+ ## In Claude (no install)
9
+
10
+ Add `https://mcp.scrupp.com/sn` as a custom connector in Claude and sign in to Scrupp.
11
+ Setup steps, what each tool needs and troubleshooting: https://scrupp.com/docs/api/claude
12
+
13
+ ## In Claude Code (plugin)
14
+
15
+ ```
16
+ /plugin install scrupp --marketplace Wism-Corp/scrupp-mcp-server
17
+ ```
18
+
19
+ On Claude Code older than 2.1.275, in two steps:
20
+
21
+ ```
22
+ /plugin marketplace add Wism-Corp/scrupp-mcp-server
23
+ /plugin install scrupp@scrupp
24
+ ```
25
+
26
+ The plugin connects Claude Code to the hosted connector at `https://mcp.scrupp.com/sn`,
27
+ the same full tool set as the private Claude connector, so nothing is installed
28
+ locally. The first time a tool runs, open `/mcp`, pick `scrupp` and sign in to Scrupp.
29
+
30
+ The plugin carries two things:
31
+
32
+ - `.mcp.json` — the hosted connector above, signed in with OAuth.
33
+ - `skills/scrupp-prospecting` — how Claude should use the tools: check credits
34
+ first, pick the tool from what the user has, export five records as a sample
35
+ before the whole search, collect long jobs with `scrupp_get_job`, and what to
36
+ say when a tool refuses.
37
+
38
+ Check it before a release with `claude plugin validate .`.
39
+
40
+ ## Using it
41
+
42
+ ```json
43
+ {
44
+ "mcpServers": {
45
+ "scrupp": {
46
+ "command": "npx",
47
+ "args": ["-y", "scrupp-mcp-server"],
48
+ "env": { "SCRUPP_API_KEY": "sk_live_..." }
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ `SCRUPP_WAIT_SECONDS` (default 120) caps how long a tool blocks. A job that outruns it
55
+ comes back as `{ status: "running", job_id }` with a note telling the agent to call
56
+ `scrupp_get_job` — better than holding a conversation open for twenty minutes on a
57
+ 2,500-record export.
58
+
59
+ ## Tools
60
+
61
+ | Tool | What it does |
62
+ | --- | --- |
63
+ | `scrupp_export_sales_navigator_search` | People out of a Sales Navigator search URL |
64
+ | `scrupp_export_linkedin_search` | People out of a LinkedIn people-search URL |
65
+ | `scrupp_run_apollo_search` | People out of an Apollo search URL (needs a connected account) |
66
+ | `scrupp_enrich_linkedin_profiles` | Full profile data for profile URLs |
67
+ | `scrupp_lookup_company` | Company data by domain, LinkedIn URL or name |
68
+ | `scrupp_find_decision_makers` | Who to write to at a company |
69
+ | `scrupp_find_emails` | Verified work emails from name + domain |
70
+ | `scrupp_verify_emails` | Deliverability check for addresses you already have (1 credit each) |
71
+ | `scrupp_get_job` | Status and records for a job that was still running |
72
+ | `scrupp_credits` | Remaining credits and plan |
73
+
74
+ Every tool carries a `title` and `readOnlyHint` / `destructiveHint` annotations — the
75
+ Claude connectors directory rejects a server without them.
76
+
77
+ ## Hosted connector (Streamable HTTP)
78
+
79
+ `node src/http.js` (or `scrupp-mcp-http`) serves the same server over Streamable HTTP
80
+ at `POST /mcp`, stateless, with `GET /health` for a load balancer. `PORT` defaults to
81
+ 8787. Each request authenticates with the caller's own key as
82
+ `Authorization: Bearer sk_live_...`; the key is scoped to that request, so one process
83
+ serves many accounts without mixing them up.
84
+
85
+ The hosted connector exposes a **narrower tool set** than the local server: email
86
+ finding, email verification, job status and credits. The tools that gather data from
87
+ LinkedIn (Sales Navigator, LinkedIn search, profile enrichment) or answer from the
88
+ shared lead base (company lookup, decision makers), and the Apollo export, stay
89
+ local-only — they run through accounts that are not the caller's, which is not
90
+ something to put in a public directory listing.
91
+
92
+ Try it in Claude Code against a deployed instance:
93
+
94
+ ```bash
95
+ claude mcp add --transport http scrupp https://mcp.scrupp.com/mcp \
96
+ --header "Authorization: Bearer sk_live_..."
97
+ ```
98
+
99
+ ### Private connector with the full API (`/sn`)
100
+
101
+ `POST /sn` serves a second tool set for Scrupp customers who add it by URL
102
+ (Claude.ai: Settings → Connectors → Add custom connector →
103
+ `https://mcp.scrupp.com/sn`). It is not listed in the directory and works on
104
+ every Scrupp plan. On top of the core tools it has the rest of the API:
105
+ `scrupp_build_sales_navigator_search` (audience in words → a Sales Navigator
106
+ search URL with the size Sales Navigator reports, no credits),
107
+ `scrupp_export_sales_navigator_search`, `scrupp_export_linkedin_search`,
108
+ `scrupp_run_apollo_search`, `scrupp_enrich_linkedin_profiles` (optionally with
109
+ emails), `scrupp_lookup_company` and `scrupp_find_decision_makers`.
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.
115
+
116
+ The OAuth resource is what tells the two apart: the protected-resource metadata
117
+ for `/sn` names `https://mcp.scrupp.com/sn`, and app.scrupp.com issues the
118
+ private connector's key (`connector:claude-sn`) only for that resource. A key
119
+ from the `/mcp` connector gets 403 on everything beyond email finding even if
120
+ it is sent to `/sn`.
121
+
122
+ ### Connecting with OAuth
123
+
124
+ Users of Claude.ai, Claude Desktop and Claude Code don't paste a key: they press
125
+ Connect, sign in to Scrupp and allow access. The server advertises its
126
+ authorization server at `/.well-known/oauth-protected-resource` (RFC 9728) and answers
127
+ an unauthenticated request with `401` and a `WWW-Authenticate` header pointing there.
128
+ The authorization server itself lives in the Scrupp app at `app.scrupp.com`
129
+ (`/oauth/register`, `/oauth/authorize`, `/oauth/token`); the access token it issues is
130
+ an ordinary Scrupp API key marked `connector:claude`, which the API restricts to the
131
+ four tools above. Access tokens last an hour, so the server checks the token before
132
+ each request (cached for a minute) and answers `401 invalid_token` when it has expired,
133
+ which is what makes the client refresh it.
134
+
135
+ | Variable | Default | Meaning |
136
+ | --- | --- | --- |
137
+ | `PORT` | `8787` | Listen port |
138
+ | `MCP_PUBLIC_URL` | `https://mcp.scrupp.com` | Public URL of this server, used in the metadata |
139
+ | `SCRUPP_AUTH_SERVER_URL` | `https://app.scrupp.com` | Where OAuth tokens are issued |
140
+ | `SCRUPP_API_URL` | `https://api.scrupp.com/api/v1` | Scrupp API the tools call |
141
+
142
+ Tool descriptions are written for a model, not a human: they say when to reach for the
143
+ tool, not just what it does. That is the whole ranking signal in a registry — a listed
144
+ server with vague descriptions gets picked and then misused.
145
+
146
+ ## Publishing
147
+
148
+ 1. Move this directory to its own repo, `Wism-Corp/scrupp-mcp-server` — the MCP registry
149
+ validates ownership from the repository and the `mcpName` in `package.json`.
150
+ 2. `npm publish --access public` with provenance from GitHub Actions, the same workflow
151
+ shape as `n8n-nodes-scrupp`.
152
+ 3. Publish the registry entry:
153
+ ```bash
154
+ npx @modelcontextprotocol/publisher login github
155
+ npx @modelcontextprotocol/publisher publish
156
+ ```
157
+ It reads `server.json`, checks the npm package carries the matching `mcpName`, and
158
+ lists the server.
159
+ 4. Then the downstream directories, which read from the registry or take a form:
160
+ Smithery, Glama, mcp.so, PulseMCP, and Anthropic's connectors directory.
161
+
162
+ ## Testing before publishing
163
+
164
+ ```bash
165
+ npm install
166
+ SCRUPP_API_KEY=... npx @modelcontextprotocol/inspector node src/index.js
167
+ ```
168
+
169
+ Run `scrupp_credits` first (free), then `scrupp_find_emails` with two people — the
170
+ cheapest call that exercises create → poll → result.
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "scrupp-mcp-server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "MCP server for Scrupp \u2014 export LinkedIn, Sales Navigator and Apollo searches, find decision makers, and find and verify work emails",
5
+ "keywords": [
6
+ "mcp",
7
+ "modelcontextprotocol",
8
+ "scrupp",
9
+ "linkedin",
10
+ "sales-navigator",
11
+ "apollo",
12
+ "lead-generation",
13
+ "email-finder"
14
+ ],
15
+ "license": "MIT",
16
+ "homepage": "https://scrupp.com/docs/api/integrations-jobs",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/Wism-Corp/scrupp-mcp-server.git"
20
+ },
21
+ "type": "module",
22
+ "bin": {
23
+ "scrupp-mcp": "src/index.js",
24
+ "scrupp-mcp-http": "src/http.js"
25
+ },
26
+ "scripts": {
27
+ "start": "node src/index.js",
28
+ "start:http": "node src/http.js"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "server.json",
33
+ "README.md"
34
+ ],
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "mcpName": "io.github.wism-corp/scrupp",
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.13.0",
41
+ "zod": "^3.24.0"
42
+ }
43
+ }
package/server.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
3
+ "name": "io.github.wism-corp/scrupp",
4
+ "description": "Export LinkedIn, Sales Navigator and Apollo searches, find decision makers at a company, and find and verify work email addresses",
5
+ "repository": {
6
+ "url": "https://github.com/Wism-Corp/scrupp-mcp-server",
7
+ "source": "github"
8
+ },
9
+ "version": "0.2.0",
10
+ "websiteUrl": "https://scrupp.com/docs/api/integrations-jobs",
11
+ "packages": [
12
+ {
13
+ "registryType": "npm",
14
+ "registryBaseUrl": "https://registry.npmjs.org",
15
+ "identifier": "scrupp-mcp-server",
16
+ "version": "0.2.0",
17
+ "transport": {
18
+ "type": "stdio"
19
+ },
20
+ "environmentVariables": [
21
+ {
22
+ "name": "SCRUPP_API_KEY",
23
+ "description": "Scrupp API key, from Settings → API Keys at app.scrupp.com",
24
+ "isRequired": true,
25
+ "isSecret": true
26
+ },
27
+ {
28
+ "name": "SCRUPP_WAIT_SECONDS",
29
+ "description": "How long a tool waits for a job before handing back its id instead. Default 120.",
30
+ "isRequired": false
31
+ }
32
+ ]
33
+ }
34
+ ]
35
+ }
package/src/client.js ADDED
@@ -0,0 +1,149 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ const BASE_URL = process.env.SCRUPP_API_URL ?? "https://api.scrupp.com/api/v1";
4
+
5
+ // Over HTTP every request carries its own caller's key; over stdio there is one
6
+ // caller and the key comes from the environment. Scoping the key to the request
7
+ // keeps one shared process from ever answering with somebody else's account.
8
+ const requestKey = new AsyncLocalStorage();
9
+
10
+ export const withApiKey = (apiKey, fn) => requestKey.run(apiKey, fn);
11
+
12
+ class ScruppError extends Error {
13
+ constructor(message, code, status) {
14
+ super(message);
15
+ this.name = "ScruppError";
16
+ this.code = code;
17
+ this.status = status;
18
+ }
19
+ }
20
+
21
+ async function call(path, { method = "GET", body, headers } = {}) {
22
+ const apiKey = requestKey.getStore() ?? process.env.SCRUPP_API_KEY;
23
+ if (!apiKey) {
24
+ throw new ScruppError("No Scrupp API key: set SCRUPP_API_KEY or send Authorization: Bearer.", "invalid_api_key");
25
+ }
26
+
27
+ const response = await fetch(`${BASE_URL}${path}`, {
28
+ method,
29
+ headers: {
30
+ Authorization: `Bearer ${apiKey}`,
31
+ "Content-Type": "application/json",
32
+ ...headers,
33
+ },
34
+ body: body === undefined ? undefined : JSON.stringify(body),
35
+ });
36
+
37
+ const payload = await response.json().catch(() => ({}));
38
+
39
+ // A 409 on the result endpoint means "not finished yet" — the caller decides.
40
+ if (!response.ok && response.status !== 409) {
41
+ throw new ScruppError(
42
+ errorText(payload.message ?? payload.error, `Scrupp returned ${response.status}`),
43
+ payload.error_code ?? payload.error?.code,
44
+ response.status,
45
+ );
46
+ }
47
+
48
+ return { status: response.status, payload };
49
+ }
50
+
51
+ /**
52
+ * Scrupp answers errors in two shapes: `{message, error_code}` and
53
+ * `{error: {code, message}}` (the Jobs API). Passing the object straight into
54
+ * Error gave Claude "[object Object]" instead of the reason.
55
+ */
56
+ export function errorText(error, fallback) {
57
+ if (typeof error === "string" && error) return error;
58
+ if (error && typeof error === "object" && typeof error.message === "string" && error.message) return error.message;
59
+ return fallback;
60
+ }
61
+
62
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
63
+
64
+ export async function ping() {
65
+ const { payload } = await call("/ping");
66
+ return payload;
67
+ }
68
+
69
+ export async function credits() {
70
+ const { payload } = await call("/account/credits");
71
+ return payload;
72
+ }
73
+
74
+ /** One address per call, one credit each — the API has no batch form. */
75
+ export async function verifyEmail(email) {
76
+ const { payload } = await call("/email/verify", { method: "POST", body: { email } });
77
+ return payload;
78
+ }
79
+
80
+ /**
81
+ * Describe an audience in words, get a Sales Navigator search URL whose size
82
+ * Sales Navigator itself reported. No credits for records — it only builds.
83
+ */
84
+ export async function buildSearch(prompt) {
85
+ const { payload } = await call("/search/build", { method: "POST", body: { prompt } });
86
+ return payload;
87
+ }
88
+
89
+ export async function createJob(type, input, idempotencyKey) {
90
+ const { payload } = await call("/jobs", {
91
+ method: "POST",
92
+ body: { type, input },
93
+ headers: idempotencyKey ? { "Idempotency-Key": idempotencyKey } : undefined,
94
+ });
95
+ return payload;
96
+ }
97
+
98
+ export async function jobStatus(jobId) {
99
+ const { payload } = await call(`/jobs/${jobId}`);
100
+ return payload;
101
+ }
102
+
103
+ export async function jobResult(jobId) {
104
+ const { status, payload } = await call(`/jobs/${jobId}/result`);
105
+ return { ready: status === 200, payload };
106
+ }
107
+
108
+ /**
109
+ * Create a job and wait, but only for as long as an agent should reasonably
110
+ * block. If the job outlives that budget we hand back the job id rather than
111
+ * holding the conversation open — the agent can call `scrupp_get_job` later.
112
+ */
113
+ export async function runJob({ type, input, idempotencyKey, waitSeconds }) {
114
+ const created = await createJob(type, input, idempotencyKey);
115
+ const deadline = Date.now() + waitSeconds * 1000;
116
+
117
+ while (Date.now() < deadline) {
118
+ const state = await jobStatus(created.job_id);
119
+
120
+ if (state.status === "failed") {
121
+ throw new ScruppError(
122
+ `Job ${created.job_id} failed: ${errorText(state.error, "unknown error")}`,
123
+ "upstream_error",
124
+ );
125
+ }
126
+
127
+ if (state.status === "succeeded") {
128
+ const { payload } = await jobResult(created.job_id);
129
+ return {
130
+ job_id: created.job_id,
131
+ status: "succeeded",
132
+ items: payload?.data?.items ?? [],
133
+ };
134
+ }
135
+
136
+ await sleep(Math.max(1, state.retry_after ?? 5) * 1000);
137
+ }
138
+
139
+ return {
140
+ job_id: created.job_id,
141
+ status: "running",
142
+ items: [],
143
+ note:
144
+ `The job is still running after ${waitSeconds}s. It keeps going on Scrupp — ` +
145
+ `call scrupp_get_job with job_id ${created.job_id} to collect the records.`,
146
+ };
147
+ }
148
+
149
+ export { ScruppError };
package/src/http.js ADDED
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The hosted connector: Streamable HTTP, stateless, one server per request.
4
+ *
5
+ * Auth is a bearer token that the Scrupp API accepts: either a key the user
6
+ * pasted, or the key app.scrupp.com issued through OAuth when they pressed
7
+ * Connect in Claude. This server never sees a password or a code — it only
8
+ * points the client at the authorization server (RFC 9728) and forwards the
9
+ * token. What a connector key may reach is enforced by the API itself
10
+ * (ConnectorKeyGuard in the Scrupp app), not here.
11
+ */
12
+ import { createServer as createHttpServer } from "node:http";
13
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
14
+ import { credits, ScruppError, withApiKey } from "./client.js";
15
+ import { createServer } from "./server.js";
16
+
17
+ const PORT = Number(process.env.PORT ?? 8787);
18
+ const MAX_BODY_BYTES = 1024 * 1024;
19
+ // The public URL of this server and of the Scrupp app that issues its tokens.
20
+ // Both are absolute in the metadata, so they come from config, not the request.
21
+ const PUBLIC_URL = (process.env.MCP_PUBLIC_URL ?? "https://mcp.scrupp.com").replace(/\/$/, "");
22
+ const AUTH_SERVER_URL = (process.env.SCRUPP_AUTH_SERVER_URL ?? "https://app.scrupp.com").replace(/\/$/, "");
23
+ const RESOURCE_METADATA_URL = `${PUBLIC_URL}/.well-known/oauth-protected-resource`;
24
+
25
+ /**
26
+ * Two connectors on one server. `/mcp` is the public one (emails only, listed
27
+ * in the Claude directory). `/sn` is the private Sales Navigator connector that
28
+ * customers add by URL. They differ in the OAuth resource: app.scrupp.com
29
+ * issues a Sales Navigator key only for the `/sn` resource, and the API, not
30
+ * this server, decides what each key may reach.
31
+ */
32
+ const ENDPOINTS = {
33
+ "/mcp": { toolset: "core", metadataUrl: RESOURCE_METADATA_URL },
34
+ "/sn": { toolset: "sn", metadataUrl: `${RESOURCE_METADATA_URL}/sn` },
35
+ };
36
+
37
+ const resourceMetadata = (path) => ({
38
+ resource: `${PUBLIC_URL}${path}`,
39
+ authorization_servers: [AUTH_SERVER_URL],
40
+ bearer_methods_supported: ["header"],
41
+ resource_documentation: "https://scrupp.com/docs/api/claude",
42
+ });
43
+
44
+ // OAuth access tokens expire hourly. A client only refreshes on HTTP 401 from
45
+ // this server, and a dead token would otherwise surface as a tool error inside
46
+ // a 200 — the connector would just stop working after an hour. So the token is
47
+ // checked before the request is handled, and the answer cached briefly.
48
+ const TOKEN_CHECK_TTL_MS = 60 * 1000;
49
+ const checkedTokens = new Map();
50
+
51
+ async function tokenIsValid(apiKey) {
52
+ const cached = checkedTokens.get(apiKey);
53
+ if (cached && cached > Date.now()) {
54
+ return true;
55
+ }
56
+ try {
57
+ await withApiKey(apiKey, () => credits());
58
+ } catch (error) {
59
+ if (error instanceof ScruppError && (error.status === 401 || error.code === "invalid_api_key")) {
60
+ checkedTokens.delete(apiKey);
61
+ return false;
62
+ }
63
+ // Scrupp being down is not the token's fault: let the tool report it.
64
+ return true;
65
+ }
66
+ if (checkedTokens.size > 10000) {
67
+ checkedTokens.clear();
68
+ }
69
+ checkedTokens.set(apiKey, Date.now() + TOKEN_CHECK_TTL_MS);
70
+ return true;
71
+ }
72
+
73
+ const unauthorized = (res, metadataUrl, message, error) => {
74
+ res.setHeader(
75
+ "WWW-Authenticate",
76
+ `Bearer resource_metadata="${metadataUrl}"${error ? `, error="${error}"` : ""}`,
77
+ );
78
+ return reply(res, 401, { jsonrpc: "2.0", error: { code: -32001, message }, id: null });
79
+ };
80
+
81
+ const reply = (res, status, body) => {
82
+ res.writeHead(status, { "Content-Type": "application/json" });
83
+ res.end(JSON.stringify(body));
84
+ };
85
+
86
+ const readJson = (req) =>
87
+ new Promise((resolve, reject) => {
88
+ let size = 0;
89
+ const chunks = [];
90
+ req.on("data", (chunk) => {
91
+ size += chunk.length;
92
+ if (size > MAX_BODY_BYTES) {
93
+ reject(new Error("Request body too large"));
94
+ req.destroy();
95
+ return;
96
+ }
97
+ chunks.push(chunk);
98
+ });
99
+ req.on("end", () => {
100
+ try {
101
+ resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : undefined);
102
+ } catch (error) {
103
+ reject(error);
104
+ }
105
+ });
106
+ req.on("error", reject);
107
+ });
108
+
109
+ const httpServer = createHttpServer(async (req, res) => {
110
+ const { pathname } = new URL(req.url, "http://localhost");
111
+
112
+ if (pathname === "/health") {
113
+ return reply(res, 200, { ok: true });
114
+ }
115
+ // RFC 9728: where a client learns which server issues tokens for a resource.
116
+ if (pathname === "/.well-known/oauth-protected-resource" || pathname === "/.well-known/oauth-protected-resource/mcp") {
117
+ return reply(res, 200, resourceMetadata("/mcp"));
118
+ }
119
+ if (pathname === "/.well-known/oauth-protected-resource/sn") {
120
+ return reply(res, 200, resourceMetadata("/sn"));
121
+ }
122
+ const endpoint = ENDPOINTS[pathname];
123
+ if (!endpoint) {
124
+ return reply(res, 404, { error: "not_found" });
125
+ }
126
+ // Stateless: no sessions to resume or close, so only POST means anything.
127
+ if (req.method !== "POST") {
128
+ return reply(res, 405, { jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null });
129
+ }
130
+
131
+ const apiKey = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization ?? "")?.[1];
132
+ if (!apiKey) {
133
+ return unauthorized(res, endpoint.metadataUrl, "Connect your Scrupp account, or send a Scrupp API key as Authorization: Bearer <key>.");
134
+ }
135
+ if (!(await tokenIsValid(apiKey))) {
136
+ return unauthorized(res, endpoint.metadataUrl, "The Scrupp token is expired or revoked.", "invalid_token");
137
+ }
138
+
139
+ let body;
140
+ try {
141
+ body = await readJson(req);
142
+ } catch {
143
+ return reply(res, 400, { jsonrpc: "2.0", error: { code: -32700, message: "Parse error" }, id: null });
144
+ }
145
+
146
+ const server = createServer({ toolset: endpoint.toolset });
147
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
148
+ res.on("close", () => {
149
+ transport.close();
150
+ server.close();
151
+ });
152
+
153
+ try {
154
+ await server.connect(transport);
155
+ await withApiKey(apiKey, () => transport.handleRequest(req, res, body));
156
+ } catch (error) {
157
+ console.error(error);
158
+ if (!res.headersSent) {
159
+ reply(res, 500, { jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null });
160
+ }
161
+ }
162
+ });
163
+
164
+ httpServer.listen(PORT, () => {
165
+ console.error(`Scrupp MCP connector listening on :${PORT}/mcp and :${PORT}/sn`);
166
+ });
package/src/index.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { createServer } from "./server.js";
4
+
5
+ await createServer({ toolset: "full" }).connect(new StdioServerTransport());
package/src/server.js ADDED
@@ -0,0 +1,336 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { buildSearch, credits, jobResult, jobStatus, runJob, ScruppError, verifyEmail } from "./client.js";
4
+
5
+ const DEFAULT_WAIT_SECONDS = Number(process.env.SCRUPP_WAIT_SECONDS ?? 120);
6
+
7
+ /**
8
+ * Tool annotations the Claude connectors directory requires on every tool.
9
+ * Spending credits is not "read-only", but nothing a tool does deletes or
10
+ * overwrites the caller's data, so none of them is destructive.
11
+ */
12
+ const READS = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
13
+ const SPENDS = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true };
14
+
15
+ /**
16
+ * `toolset: "full"` is every tool, for a user running the server on their own
17
+ * machine with their own key. `"core"` is what the hosted connector exposes:
18
+ * no tool that gathers data from LinkedIn or out of the shared lead base, only
19
+ * email finding and verification plus the bookkeeping tools. `"sn"` is the
20
+ * private connector (mcp.scrupp.com/sn): core plus the rest of the API —
21
+ * 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.
24
+ */
25
+ export function createServer({ toolset = "full" } = {}) {
26
+ // Claude reads `instructions` when the server connects: the one place to point
27
+ // it at the setup guide, so a missing LinkedIn account or empty balance ends
28
+ // with a link the person can follow, not a guess.
29
+ const server = new McpServer(
30
+ { name: "scrupp", version: "0.3.0" },
31
+ {
32
+ instructions:
33
+ "Scrupp setup guide and troubleshooting: https://scrupp.com/docs/api/claude. " +
34
+ "When a tool reports missing credits or no connected LinkedIn account, give the person " +
35
+ "the link from the error (or https://app.scrupp.com/app/plans for credits) and ask them to try again after.",
36
+ },
37
+ );
38
+ const full = toolset === "full";
39
+ const sn = toolset === "sn";
40
+
41
+ const json = (value) => ({
42
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
43
+ });
44
+
45
+ const failure = (error) => ({
46
+ isError: true,
47
+ content: [
48
+ {
49
+ type: "text",
50
+ text:
51
+ error instanceof ScruppError
52
+ ? `${error.code ?? "error"}: ${error.message}`
53
+ : String(error?.message ?? error),
54
+ },
55
+ ],
56
+ });
57
+
58
+ /**
59
+ * Every extraction tool is the same shape, so register them from a table.
60
+ * Keeping the descriptions concrete matters more here than anywhere else — the
61
+ * caller choosing between these tools is a model reading exactly this text.
62
+ */
63
+ function extractionTool({ name, title, description, schema, toInput, type }) {
64
+ server.registerTool(
65
+ name,
66
+ {
67
+ title,
68
+ description,
69
+ annotations: { title, ...SPENDS },
70
+ inputSchema: {
71
+ ...schema,
72
+ wait_seconds: z
73
+ .number()
74
+ .int()
75
+ .min(0)
76
+ .optional()
77
+ .describe(
78
+ `How long to wait for the job before returning its id instead (default ${DEFAULT_WAIT_SECONDS}).`,
79
+ ),
80
+ },
81
+ },
82
+ async (args) => {
83
+ try {
84
+ const jobType = typeof type === "function" ? type(args) : type;
85
+ const result = await runJob({
86
+ type: jobType,
87
+ input: toInput(args),
88
+ idempotencyKey: `mcp-${jobType}-${JSON.stringify(toInput(args))}`.slice(0, 200),
89
+ waitSeconds: args.wait_seconds ?? DEFAULT_WAIT_SECONDS,
90
+ });
91
+ return json(result);
92
+ } catch (error) {
93
+ return failure(error);
94
+ }
95
+ },
96
+ );
97
+ }
98
+
99
+ const searchSchema = {
100
+ url: z.string().url().describe("The search URL, copied from the platform after building the filters."),
101
+ with_emails: z.boolean().optional().describe("Also find work email addresses. Defaults to true."),
102
+ max: z
103
+ .number()
104
+ .int()
105
+ .min(1)
106
+ .optional()
107
+ .describe("Upper bound on records. Drives the credit hold; unused credits are refunded. Defaults to 100."),
108
+ account: z.string().optional().describe("Connected account email to run the search with."),
109
+ };
110
+
111
+ const searchInput = (args) => ({
112
+ url: args.url,
113
+ with_emails: args.with_emails ?? true,
114
+ max: args.max ?? 100,
115
+ ...(args.account ? { account: args.account } : {}),
116
+ });
117
+
118
+ if (sn) {
119
+ server.registerTool(
120
+ "scrupp_build_sales_navigator_search",
121
+ {
122
+ title: "Build a Sales Navigator search",
123
+ annotations: { title: "Build a Sales Navigator search", readOnlyHint: true, destructiveHint: false, openWorldHint: true },
124
+ description:
125
+ "Turn a plain-words audience (role, industry, country or cities, company size) into a Sales Navigator search URL, " +
126
+ "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 " +
128
+ "from what the user wants, rephrase the audience and build again before exporting.",
129
+ inputSchema: {
130
+ audience: z
131
+ .string()
132
+ .min(3)
133
+ .describe('The audience in words, e.g. "operations directors in logistics, United States, 50-200 employees".'),
134
+ },
135
+ },
136
+ async ({ audience }) => {
137
+ try {
138
+ return json(await buildSearch(audience));
139
+ } catch (error) {
140
+ return failure(error);
141
+ }
142
+ },
143
+ );
144
+
145
+ extractionTool({
146
+ name: "scrupp_export_sales_navigator_search",
147
+ title: "Export a Sales Navigator search",
148
+ description:
149
+ "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` " +
152
+ "to confirm the list looks right before exporting the whole search.",
153
+ schema: {
154
+ ...searchSchema,
155
+ account: z
156
+ .string()
157
+ .optional()
158
+ .describe("Which of the user's own connected LinkedIn accounts to use, by email. Defaults to one with Sales Navigator."),
159
+ },
160
+ toInput: searchInput,
161
+ type: "sales_navigator.search",
162
+ });
163
+ }
164
+
165
+ if (full) {
166
+ extractionTool({
167
+ name: "scrupp_export_sales_navigator_search",
168
+ title: "Export a Sales Navigator search",
169
+ description:
170
+ "Extract the people from a LinkedIn Sales Navigator search URL, with their titles, companies and (by default) verified work emails. Use this when the user has a Sales Navigator search they want as a list.",
171
+ schema: searchSchema,
172
+ toInput: searchInput,
173
+ type: "sales_navigator.search",
174
+ });
175
+ }
176
+
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.
180
+ 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."
182
+ : "";
183
+
184
+ if (full || sn) {
185
+ extractionTool({
186
+ name: "scrupp_export_linkedin_search",
187
+ title: "Export a LinkedIn search",
188
+ description:
189
+ "Extract the people from a regular LinkedIn people-search URL. Use this for linkedin.com/search/results/people URLs; use the Sales Navigator tool for linkedin.com/sales URLs." + ownAccounts,
190
+ schema: searchSchema,
191
+ toInput: searchInput,
192
+ type: "linkedin.search",
193
+ });
194
+
195
+ extractionTool({
196
+ name: "scrupp_run_apollo_search",
197
+ title: "Run an Apollo search",
198
+ description:
199
+ "Extract the people from an Apollo search URL. Requires a connected Apollo account, passed as `account`." + ownAccounts,
200
+ schema: { ...searchSchema, account: z.string().describe("Connected Apollo account email. Required.") },
201
+ toInput: searchInput,
202
+ type: "apollo.search",
203
+ });
204
+
205
+ extractionTool({
206
+ name: "scrupp_enrich_linkedin_profiles",
207
+ title: "Enrich LinkedIn profiles",
208
+ description:
209
+ "Full profile data for LinkedIn profile URLs: current title, company, location, experience." + ownAccounts,
210
+ schema: {
211
+ profile_urls: z.array(z.string().url()).min(1).describe("LinkedIn profile URLs."),
212
+ with_emails: z
213
+ .boolean()
214
+ .optional()
215
+ .describe("Also find and verify work emails (billed per enriched profile plus per email found). Defaults to false."),
216
+ },
217
+ toInput: (args) => ({ items: args.profile_urls }),
218
+ type: (args) => (args.with_emails ? "email.linkedin" : "linkedin.profile"),
219
+ });
220
+
221
+ extractionTool({
222
+ name: "scrupp_lookup_company",
223
+ title: "Look up a company",
224
+ description:
225
+ "Company data — size, industry, location, website, LinkedIn — from a domain, a company LinkedIn URL, or a name." + ownAccounts,
226
+ schema: {
227
+ lookup_by: z.enum(["domain", "linkedin", "name"]).describe("What the values in `companies` are."),
228
+ companies: z.array(z.string()).min(1).describe("Domains, LinkedIn URLs or names."),
229
+ },
230
+ toInput: (args) => ({ items: args.companies }),
231
+ type: (args) => `company.${args.lookup_by}`,
232
+ });
233
+
234
+ extractionTool({
235
+ name: "scrupp_find_decision_makers",
236
+ title: "Find decision makers at a company",
237
+ description:
238
+ "The people worth writing to at a company you only know the domain, LinkedIn URL or name of. Returns names, titles and (where found) work emails." + ownAccounts,
239
+ schema: {
240
+ find_by: z.enum(["domain", "linkedin", "name"]).describe("What the values in `companies` are."),
241
+ companies: z.array(z.string()).min(1).describe("Domains, LinkedIn URLs or names."),
242
+ max: z.number().int().min(1).max(2500).optional().describe("Decision makers per company. Defaults to 10."),
243
+ },
244
+ toInput: (args) => ({ items: args.companies, max: args.max ?? 10 }),
245
+ type: (args) => `contact.decision_maker.${args.find_by}`,
246
+ });
247
+ }
248
+
249
+ extractionTool({
250
+ name: "scrupp_find_emails",
251
+ title: "Find work emails",
252
+ description:
253
+ "Find and verify work email addresses from first name, last name and company domain. Addresses that cannot be verified come back marked rather than guessed.",
254
+ schema: {
255
+ people: z
256
+ .array(
257
+ z.object({
258
+ first_name: z.string(),
259
+ last_name: z.string(),
260
+ domain: z.string().describe("Company domain, for example acme.com."),
261
+ }),
262
+ )
263
+ .min(1),
264
+ },
265
+ toInput: (args) => ({ items: args.people }),
266
+ type: "email.initials",
267
+ });
268
+
269
+ server.registerTool(
270
+ "scrupp_get_job",
271
+ {
272
+ title: "Check or collect a Scrupp job",
273
+ annotations: { title: "Check or collect a Scrupp job", ...READS },
274
+ description:
275
+ "Status of a job, and its records once it has finished. Use this for a job that was still running when the tool that started it returned.",
276
+ inputSchema: { job_id: z.number().int().describe("The job id returned by an extraction tool.") },
277
+ },
278
+ async ({ job_id }) => {
279
+ try {
280
+ const state = await jobStatus(job_id);
281
+ if (state.status !== "succeeded") {
282
+ return json(state);
283
+ }
284
+ const { payload } = await jobResult(job_id);
285
+ return json({ job_id, status: "succeeded", items: payload?.data?.items ?? [] });
286
+ } catch (error) {
287
+ return failure(error);
288
+ }
289
+ },
290
+ );
291
+
292
+ server.registerTool(
293
+ "scrupp_credits",
294
+ {
295
+ title: "Credit balance",
296
+ annotations: { title: "Credit balance", ...READS },
297
+ description:
298
+ "Remaining Scrupp credits and the current plan. Worth checking before starting a large export — one credit is one record requested.",
299
+ inputSchema: {},
300
+ },
301
+ async () => {
302
+ try {
303
+ return json(await credits());
304
+ } catch (error) {
305
+ return failure(error);
306
+ }
307
+ },
308
+ );
309
+
310
+ server.registerTool(
311
+ "scrupp_verify_emails",
312
+ {
313
+ title: "Verify email addresses",
314
+ annotations: { title: "Verify email addresses", ...SPENDS, idempotentHint: false },
315
+ description:
316
+ "Check whether email addresses are deliverable before sending to them. Costs one credit per address; use it on addresses you already have, not on ones scrupp_find_emails just returned (those are verified already).",
317
+ inputSchema: {
318
+ emails: z.array(z.string().email()).min(1).max(100).describe("Addresses to verify, at most 100 per call."),
319
+ },
320
+ },
321
+ async ({ emails }) => {
322
+ const results = [];
323
+ for (const email of emails) {
324
+ try {
325
+ results.push({ email, ...(await verifyEmail(email)) });
326
+ } catch (error) {
327
+ // One bad address must not throw away the answers already paid for.
328
+ results.push({ email, error: error?.message ?? String(error) });
329
+ }
330
+ }
331
+ return json({ results });
332
+ },
333
+ );
334
+
335
+ return server;
336
+ }