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 +21 -0
- package/README.md +169 -2
- package/package.json +41 -4
- package/server.json +35 -0
- package/src/client.js +149 -0
- package/src/http.js +166 -0
- package/src/index.js +5 -0
- package/src/server.js +336 -0
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
|
-
#
|
|
1
|
+
# Scrupp MCP server
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
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
|
+
}
|