@thepulseapi/mcp 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 +52 -2
- package/package.json +41 -4
- package/server.mjs +318 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Resolve Labs
|
|
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,53 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @thepulseapi/mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The [PULSE developer documentation](https://developer.thepulseapi.com) as an MCP server, so an
|
|
4
|
+
agent can search and read it rather than recalling an endpoint that may not exist. And, with an
|
|
5
|
+
API key, the PULSE Decision API: which of these products meet what the shopper asked for, and
|
|
6
|
+
which the listing cannot tell.
|
|
7
|
+
|
|
8
|
+
```json
|
|
9
|
+
{
|
|
10
|
+
"mcpServers": {
|
|
11
|
+
"pulse": {
|
|
12
|
+
"command": "npx",
|
|
13
|
+
"args": ["-y", "@thepulseapi/mcp"]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Tools
|
|
20
|
+
|
|
21
|
+
| Tool | What it does |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `search_docs` | Full-text search across every page. Returns the matching section, its URL and a score |
|
|
24
|
+
| `get_page` | One page as Markdown, by path |
|
|
25
|
+
| `list_pages` | Every page with its title |
|
|
26
|
+
| `resolve_products` | Per candidate and requirement: meets, fails or cannot tell, and what is missing. Calls `POST /decide`; needs `PULSE_API_KEY` |
|
|
27
|
+
|
|
28
|
+
The three docs tools take a `lang` of `en` or `vi`.
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"mcpServers": {
|
|
33
|
+
"pulse": {
|
|
34
|
+
"command": "npx",
|
|
35
|
+
"args": ["-y", "@thepulseapi/mcp"],
|
|
36
|
+
"env": { "PULSE_API_KEY": "pk_live_..." }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Notes
|
|
43
|
+
|
|
44
|
+
It runs on your machine and fetches the published index at startup, so it is current without a
|
|
45
|
+
republish. The docs tools send nothing about your prompts to us; `resolve_products` sends its own
|
|
46
|
+
input to the PULSE API, and only once a key is set. Set `PULSE_DOCS_ORIGIN` to read from a
|
|
47
|
+
different origin while working on the documentation itself.
|
|
48
|
+
|
|
49
|
+
Search is lexical rather than semantic on purpose: the caller is a language model that can read
|
|
50
|
+
three candidate sections and judge, and an agent asking about `/catalog/audit` wants the page
|
|
51
|
+
with that exact string.
|
|
52
|
+
|
|
53
|
+
MIT.
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thepulseapi/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server for PULSE: search the developer documentation, and ask the Decision API which products meet a shopper's requirements.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"pulse-mcp": "./server.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"server.mjs",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
20
|
+
"zod": "^4.6.7"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/mypulseapi/pulse.git",
|
|
25
|
+
"directory": "packages/mcp"
|
|
26
|
+
},
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/mypulseapi/pulse/issues"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://developer.thepulseapi.com/mcp",
|
|
31
|
+
"keywords": [
|
|
32
|
+
"mcp",
|
|
33
|
+
"model-context-protocol",
|
|
34
|
+
"ecommerce",
|
|
35
|
+
"ai-agents",
|
|
36
|
+
"claude",
|
|
37
|
+
"chatgpt",
|
|
38
|
+
"pulse"
|
|
39
|
+
],
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/server.mjs
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The PULSE documentation, as an MCP server.
|
|
4
|
+
*
|
|
5
|
+
* npx @thepulseapi/mcp
|
|
6
|
+
*
|
|
7
|
+
* **Why a local stdio server and not a hosted one.** developer.thepulseapi.com is a static export
|
|
8
|
+
* on GitHub Pages: there is no process to host an HTTP MCP endpoint on. This runs wherever the
|
|
9
|
+
* agent runs, fetches the published index, and needs nothing from us but a CDN.
|
|
10
|
+
*
|
|
11
|
+
* **It reads the same index the website reads.** One corpus, two front ends, so an answer an agent
|
|
12
|
+
* gets and an answer a human gets come from the same chunks. The index is fetched at startup
|
|
13
|
+
* rather than bundled, because a bundled copy goes stale the moment a page is edited and the
|
|
14
|
+
* server has no way to know.
|
|
15
|
+
*
|
|
16
|
+
* Search is lexical here, deliberately. The website embeds the query with a 23 MB model it has
|
|
17
|
+
* already paid to download; making an agent download one to ask a question would be a poor
|
|
18
|
+
* trade when the agent is itself a language model and can read three candidate sections and
|
|
19
|
+
* decide. So this returns more, ranks it honestly, and lets the caller judge.
|
|
20
|
+
*/
|
|
21
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
22
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
|
|
25
|
+
const SITE =
|
|
26
|
+
process.env.PULSE_DOCS_ORIGIN ?? "https://developer.thepulseapi.com";
|
|
27
|
+
const INDEX_URL = `${SITE}/search-index.json`;
|
|
28
|
+
|
|
29
|
+
/** Tokens, lowercased, with the short noise words dropped. */
|
|
30
|
+
const STOP = new Set(
|
|
31
|
+
"a an and are as at be but by для for from has have how i if in into is it its of on or that the then there these this to was what when where which who why will with you your".split(
|
|
32
|
+
" ",
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
function tokens(text) {
|
|
37
|
+
return (
|
|
38
|
+
text.toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}_/-]*/gu) ?? []
|
|
39
|
+
).filter((w) => w.length > 1 && !STOP.has(w));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* BM25, which is the right tool when the query is written by something that knows what it means.
|
|
44
|
+
*
|
|
45
|
+
* An agent asking about `/catalog/audit` wants the page with that literal string far more than it
|
|
46
|
+
* wants a semantic neighbour, and lexical scoring is exact about that where an embedding blurs it.
|
|
47
|
+
*/
|
|
48
|
+
function makeRanker(chunks) {
|
|
49
|
+
const docs = chunks.map((c) => tokens(`${c.p} ${c.h} ${c.t}`));
|
|
50
|
+
const avgLen =
|
|
51
|
+
docs.reduce((n, d) => n + d.length, 0) / Math.max(1, docs.length);
|
|
52
|
+
const df = new Map();
|
|
53
|
+
for (const d of docs)
|
|
54
|
+
for (const w of new Set(d)) df.set(w, (df.get(w) ?? 0) + 1);
|
|
55
|
+
|
|
56
|
+
const tf = docs.map((d) => {
|
|
57
|
+
const m = new Map();
|
|
58
|
+
for (const w of d) m.set(w, (m.get(w) ?? 0) + 1);
|
|
59
|
+
return m;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const k1 = 1.4;
|
|
63
|
+
const b = 0.72;
|
|
64
|
+
const N = docs.length;
|
|
65
|
+
|
|
66
|
+
return (query, { lang, limit }) => {
|
|
67
|
+
const qs = tokens(query);
|
|
68
|
+
if (qs.length === 0) return [];
|
|
69
|
+
|
|
70
|
+
const scored = [];
|
|
71
|
+
for (let i = 0; i < chunks.length; i += 1) {
|
|
72
|
+
if (lang && chunks[i].l !== lang) continue;
|
|
73
|
+
let score = 0;
|
|
74
|
+
for (const w of qs) {
|
|
75
|
+
const f = tf[i].get(w);
|
|
76
|
+
if (!f) continue;
|
|
77
|
+
const idf = Math.log(
|
|
78
|
+
1 + (N - (df.get(w) ?? 0) + 0.5) / ((df.get(w) ?? 0) + 0.5),
|
|
79
|
+
);
|
|
80
|
+
score +=
|
|
81
|
+
idf *
|
|
82
|
+
((f * (k1 + 1)) / (f + k1 * (1 - b + (b * docs[i].length) / avgLen)));
|
|
83
|
+
}
|
|
84
|
+
if (score > 0) scored.push({ i, score });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
scored.sort((x, y) => y.score - x.score);
|
|
88
|
+
|
|
89
|
+
// One section per page, so a long page cannot fill the whole result set.
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const s of scored) {
|
|
93
|
+
const c = chunks[s.i];
|
|
94
|
+
if (seen.has(c.u)) continue;
|
|
95
|
+
seen.add(c.u);
|
|
96
|
+
out.push({ chunk: c, score: Number(s.score.toFixed(3)) });
|
|
97
|
+
if (out.length >= limit) break;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function load() {
|
|
104
|
+
const res = await fetch(INDEX_URL, {
|
|
105
|
+
headers: { accept: "application/json" },
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) throw new Error(`Could not fetch ${INDEX_URL}: ${res.status}`);
|
|
108
|
+
return res.json();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const index = await load();
|
|
112
|
+
const rank = makeRanker(index.chunks);
|
|
113
|
+
const pageUrls = [...new Set(index.chunks.map((c) => c.u))].sort();
|
|
114
|
+
|
|
115
|
+
const mcp = new McpServer({ name: "pulse", version: "0.2.0" });
|
|
116
|
+
|
|
117
|
+
mcp.registerTool(
|
|
118
|
+
"search_docs",
|
|
119
|
+
{
|
|
120
|
+
title: "Search the PULSE documentation",
|
|
121
|
+
description:
|
|
122
|
+
"Full-text search across every page of the PULSE developer documentation. Returns the " +
|
|
123
|
+
"matching section of each page with its URL, so you can cite it or fetch the whole page " +
|
|
124
|
+
"with get_page. Prefer this over guessing an endpoint or an SDK method.",
|
|
125
|
+
inputSchema: {
|
|
126
|
+
query: z
|
|
127
|
+
.string()
|
|
128
|
+
.describe("What you want to know, or a literal like /catalog/audit"),
|
|
129
|
+
lang: z
|
|
130
|
+
.enum(["en", "vi"])
|
|
131
|
+
.default("en")
|
|
132
|
+
.describe("Documentation language"),
|
|
133
|
+
limit: z.number().int().min(1).max(10).default(5),
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
async ({ query, lang, limit }) => {
|
|
137
|
+
const hits = rank(query, { lang, limit });
|
|
138
|
+
if (hits.length === 0) {
|
|
139
|
+
return {
|
|
140
|
+
content: [
|
|
141
|
+
{
|
|
142
|
+
type: "text",
|
|
143
|
+
text: `No section of the documentation matches "${query}".`,
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
content: [
|
|
150
|
+
{
|
|
151
|
+
type: "text",
|
|
152
|
+
text: hits
|
|
153
|
+
.map(
|
|
154
|
+
(h) =>
|
|
155
|
+
`## ${h.chunk.p} — ${h.chunk.h}\n` +
|
|
156
|
+
`${SITE}${h.chunk.u}${h.chunk.a ? `#${h.chunk.a}` : ""} (score ${h.score})\n\n` +
|
|
157
|
+
h.chunk.t,
|
|
158
|
+
)
|
|
159
|
+
.join("\n\n---\n\n"),
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
mcp.registerTool(
|
|
167
|
+
"get_page",
|
|
168
|
+
{
|
|
169
|
+
title: "Read one documentation page",
|
|
170
|
+
description:
|
|
171
|
+
"Fetch a full documentation page as Markdown. Use the url from search_docs, for example " +
|
|
172
|
+
"/quickstart or /api/v1/audit.",
|
|
173
|
+
inputSchema: {
|
|
174
|
+
url: z.string().describe("Page path, such as /quickstart or /vi/sdk"),
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
async ({ url }) => {
|
|
178
|
+
const path = url.startsWith("/") ? url : `/${url}`;
|
|
179
|
+
const slug = path.replace(/^\/(vi)(?=\/|$)/, "") || "/";
|
|
180
|
+
const lang = path.startsWith("/vi") ? ".vi" : "";
|
|
181
|
+
const target = `${SITE}/llms.mdx/docs${slug === "/" ? "" : slug}/content.md`;
|
|
182
|
+
|
|
183
|
+
const res = await fetch(target);
|
|
184
|
+
if (!res.ok) {
|
|
185
|
+
return {
|
|
186
|
+
content: [
|
|
187
|
+
{
|
|
188
|
+
type: "text",
|
|
189
|
+
text: `No page at ${path}. Known pages:\n${pageUrls.join("\n")}`,
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
isError: true,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
void lang;
|
|
196
|
+
return { content: [{ type: "text", text: await res.text() }] };
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
mcp.registerTool(
|
|
201
|
+
"list_pages",
|
|
202
|
+
{
|
|
203
|
+
title: "List every documentation page",
|
|
204
|
+
description:
|
|
205
|
+
"Every page in the PULSE developer documentation, with its title. Useful for orienting " +
|
|
206
|
+
"before searching.",
|
|
207
|
+
inputSchema: { lang: z.enum(["en", "vi"]).default("en") },
|
|
208
|
+
},
|
|
209
|
+
async ({ lang }) => {
|
|
210
|
+
const seen = new Map();
|
|
211
|
+
for (const c of index.chunks) {
|
|
212
|
+
if (c.l !== lang) continue;
|
|
213
|
+
if (!seen.has(c.u)) seen.set(c.u, c.p);
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
content: [
|
|
217
|
+
{
|
|
218
|
+
type: "text",
|
|
219
|
+
text: [...seen.entries()]
|
|
220
|
+
.map(([u, title]) => `${u} ${title}`)
|
|
221
|
+
.join("\n"),
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
/*
|
|
229
|
+
* The one tool that calls PULSE rather than reading the docs: the Decision API, sold per decision.
|
|
230
|
+
*
|
|
231
|
+
* Everything above runs on the caller's machine and sends nothing. This sends the shopper's words
|
|
232
|
+
* and the candidates to `POST /decide`, and only when the user has put an API key in the config,
|
|
233
|
+
* so a docs-only install never makes a call it did not ask for.
|
|
234
|
+
*/
|
|
235
|
+
const API = (
|
|
236
|
+
process.env.PULSE_API_URL ?? "https://api.thepulseapi.com/api/v1"
|
|
237
|
+
).replace(/\/+$/, "");
|
|
238
|
+
const KEY = process.env.PULSE_API_KEY;
|
|
239
|
+
|
|
240
|
+
mcp.registerTool(
|
|
241
|
+
"resolve_products",
|
|
242
|
+
{
|
|
243
|
+
title: "Decide which products meet the shopper's requirements",
|
|
244
|
+
description:
|
|
245
|
+
"For each candidate product, says whether the evidence on the product (its fields, title and " +
|
|
246
|
+
"description) shows it meets each requirement, fails it, or cannot tell, and what is missing. " +
|
|
247
|
+
'Use it before recommending a product, so you can say "the listing does not state the RAM" ' +
|
|
248
|
+
"instead of guessing. Needs PULSE_API_KEY. Requirements use op lte, gte, eq or neq; a budget " +
|
|
249
|
+
'in the query, such as "under 2 triệu" or "below $1,500", becomes a price requirement.',
|
|
250
|
+
inputSchema: {
|
|
251
|
+
query: z
|
|
252
|
+
.string()
|
|
253
|
+
.max(500)
|
|
254
|
+
.optional()
|
|
255
|
+
.describe("The shopper's request in their own words"),
|
|
256
|
+
requirements: z
|
|
257
|
+
.array(
|
|
258
|
+
z.object({
|
|
259
|
+
attribute: z
|
|
260
|
+
.string()
|
|
261
|
+
.describe(
|
|
262
|
+
"For example ram, storage, screen, cpu, price, or any field name",
|
|
263
|
+
),
|
|
264
|
+
op: z.enum(["lte", "gte", "eq", "neq"]),
|
|
265
|
+
value: z
|
|
266
|
+
.union([z.number(), z.string()])
|
|
267
|
+
.describe("GB for ram and storage, inches for screen"),
|
|
268
|
+
}),
|
|
269
|
+
)
|
|
270
|
+
.max(20)
|
|
271
|
+
.optional(),
|
|
272
|
+
products: z
|
|
273
|
+
.array(
|
|
274
|
+
z.object({
|
|
275
|
+
id: z.string(),
|
|
276
|
+
title: z.string().optional(),
|
|
277
|
+
description: z.string().max(20000).optional(),
|
|
278
|
+
price: z.number().optional(),
|
|
279
|
+
attributes: z.record(z.string(), z.unknown()).optional(),
|
|
280
|
+
}),
|
|
281
|
+
)
|
|
282
|
+
.min(1)
|
|
283
|
+
.max(50),
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
async (input) => {
|
|
287
|
+
if (!KEY) {
|
|
288
|
+
return {
|
|
289
|
+
isError: true,
|
|
290
|
+
content: [
|
|
291
|
+
{
|
|
292
|
+
type: "text",
|
|
293
|
+
text:
|
|
294
|
+
"resolve_products needs a PULSE API key. Create one in the dashboard under " +
|
|
295
|
+
'Integration, API keys, then add "env": { "PULSE_API_KEY": "pk_..." } to this ' +
|
|
296
|
+
"server in the MCP config. See https://developer.thepulseapi.com/mcp",
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const res = await fetch(`${API}/decide`, {
|
|
302
|
+
method: "POST",
|
|
303
|
+
headers: { "content-type": "application/json", "x-api-key": KEY },
|
|
304
|
+
body: JSON.stringify(input),
|
|
305
|
+
});
|
|
306
|
+
const text = await res.text();
|
|
307
|
+
return res.ok
|
|
308
|
+
? { content: [{ type: "text", text }] }
|
|
309
|
+
: {
|
|
310
|
+
isError: true,
|
|
311
|
+
content: [
|
|
312
|
+
{ type: "text", text: `PULSE answered ${res.status}: ${text}` },
|
|
313
|
+
],
|
|
314
|
+
};
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
await mcp.connect(new StdioServerTransport());
|