onesignal-mcp 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/api.d.ts +0 -9
- package/dist/api.js +0 -96
- package/dist/gateway.d.ts +16 -0
- package/dist/gateway.js +85 -0
- package/dist/server.js +6 -10
- package/package.json +1 -1
- package/dist/license.js +0 -79
package/README.md
CHANGED
|
@@ -15,18 +15,18 @@ Query the OneSignal API (free tier; use a current v2 Organization API key for ON
|
|
|
15
15
|
<!-- paywall -->
|
|
16
16
|
## Premium tools
|
|
17
17
|
|
|
18
|
-
These tools need a
|
|
18
|
+
These tools need a licence key:
|
|
19
19
|
|
|
20
20
|
* `sdk_release_timeline`
|
|
21
21
|
* `platform_status`
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
The tool bodies run on our gateway, not inside this package, so the key is what buys access. Buy one at https://buy.polar.sh/polar_cl_mrO7rS5LZxpqM7oAFFEA10i9121J240U40IIj4JRZO4 and set it in your MCP client config:
|
|
24
24
|
|
|
25
25
|
```json
|
|
26
|
-
"env": { "MCP_LICENSE_KEY": "
|
|
26
|
+
"env": { "MCP_LICENSE_KEY": "polar_..." }
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
The
|
|
30
|
-
|
|
29
|
+
The gateway checks the key against Polar and gives every key 1,000 free premium
|
|
30
|
+
calls a month before per-call billing starts. Every other tool on this server
|
|
31
31
|
stays free.
|
|
32
32
|
<!-- /paywall -->
|
package/dist/api.d.ts
CHANGED
|
@@ -7,12 +7,3 @@ export declare function getApp(appId: string): Promise<string>;
|
|
|
7
7
|
export declare function listNotifications(appId: string, limit?: string): Promise<string>;
|
|
8
8
|
/** Which OneSignal SDK people actually install, and how big each one is. */
|
|
9
9
|
export declare function pushSdkPopularity(_args: Record<string, never>): Promise<string>;
|
|
10
|
-
/** The release train of one OneSignal SDK, with dates, tags and how often it ships. */
|
|
11
|
-
export declare function sdkReleaseTimeline(args: {
|
|
12
|
-
package?: string;
|
|
13
|
-
limit?: number;
|
|
14
|
-
}): Promise<string>;
|
|
15
|
-
/** OneSignal's own record of its reliability, per product area, with the incident history behind it. */
|
|
16
|
-
export declare function platformStatus(args: {
|
|
17
|
-
limit?: number;
|
|
18
|
-
}): Promise<string>;
|
package/dist/api.js
CHANGED
|
@@ -48,9 +48,7 @@ export function listNotifications(appId, limit) {
|
|
|
48
48
|
// ---- muxG tools:
|
|
49
49
|
const ONESIGNAL_NPM = "https://registry.npmjs.org";
|
|
50
50
|
const ONESIGNAL_DOWNLOADS = "https://api.npmjs.org/downloads/point/last-week";
|
|
51
|
-
const ONESIGNAL_STATUS = "https://status.onesignal.com/api/v2";
|
|
52
51
|
const ONESIGNAL_SDKS = ["onesignal-node", "react-native-onesignal", "onesignal-cordova-plugin"];
|
|
53
|
-
const ONESIGNAL_LIST_CAP = 15;
|
|
54
52
|
async function onesignalJson(url, accept = "application/json") {
|
|
55
53
|
const res = await fetch(url, {
|
|
56
54
|
headers: { "User-Agent": "awesome-mcps/1.0", Accept: accept },
|
|
@@ -73,19 +71,6 @@ async function onesignalWeeklyDownloads(pkg) {
|
|
|
73
71
|
return null;
|
|
74
72
|
}
|
|
75
73
|
}
|
|
76
|
-
function onesignalBound(value, min, max, fallback) {
|
|
77
|
-
const n = Number(value);
|
|
78
|
-
if (!Number.isFinite(n))
|
|
79
|
-
return fallback;
|
|
80
|
-
return Math.max(min, Math.min(Math.floor(n), max));
|
|
81
|
-
}
|
|
82
|
-
function onesignalMedian(values) {
|
|
83
|
-
if (!values.length)
|
|
84
|
-
return 0;
|
|
85
|
-
const s = [...values].sort((a, b) => a - b);
|
|
86
|
-
const mid = Math.floor(s.length / 2);
|
|
87
|
-
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
88
|
-
}
|
|
89
74
|
/** Which OneSignal SDK people actually install, and how big each one is. */
|
|
90
75
|
export async function pushSdkPopularity(_args) {
|
|
91
76
|
const docs = await Promise.all(ONESIGNAL_SDKS.map(async (pkg) => {
|
|
@@ -114,84 +99,3 @@ export async function pushSdkPopularity(_args) {
|
|
|
114
99
|
"Download counts are npm's own, so a package that is also used from a lockfile, a private registry or a vendored copy will undercount. The relative share is the useful signal, not the absolute number.",
|
|
115
100
|
].join("\n");
|
|
116
101
|
}
|
|
117
|
-
/** The release train of one OneSignal SDK, with dates, tags and how often it ships. */
|
|
118
|
-
export async function sdkReleaseTimeline(args) {
|
|
119
|
-
const pkg = String(args?.package ?? "").trim() || ONESIGNAL_SDKS[0];
|
|
120
|
-
if (!ONESIGNAL_SDKS.includes(pkg)) {
|
|
121
|
-
throw new OneSignalError(`"${pkg}" is not one of the tracked OneSignal SDKs: ${ONESIGNAL_SDKS.join(", ")}.`);
|
|
122
|
-
}
|
|
123
|
-
const limit = onesignalBound(args?.limit, 1, ONESIGNAL_LIST_CAP, 8);
|
|
124
|
-
const doc = await onesignalJson(`${ONESIGNAL_NPM}/${encodeURIComponent(pkg)}`);
|
|
125
|
-
const time = doc.time ?? {};
|
|
126
|
-
const published = Object.entries(time)
|
|
127
|
-
.filter(([k]) => k !== "created" && k !== "modified")
|
|
128
|
-
.map(([version, date]) => ({ version, date }))
|
|
129
|
-
.sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
|
|
130
|
-
if (!published.length)
|
|
131
|
-
throw new OneSignalError(`npm returned no publish dates for ${pkg}.`);
|
|
132
|
-
const tags = Object.entries(doc["dist-tags"] ?? {});
|
|
133
|
-
const stableTags = tags.filter(([k]) => k === "latest" || k === "next" || k === "beta");
|
|
134
|
-
const prereleaseTags = tags.filter(([k, v]) => !stableTags.some(([sk]) => sk === k) && /-(alpha|beta|rc|next|pre|canary)/.test(v));
|
|
135
|
-
const gaps = [];
|
|
136
|
-
for (let i = 1; i < published.length; i += 1) {
|
|
137
|
-
gaps.push(Math.abs((Date.parse(published[i - 1].date) - Date.parse(published[i].date)) / 86400000));
|
|
138
|
-
}
|
|
139
|
-
const latestManifest = doc["dist-tags"]?.latest ? doc.versions?.[doc["dist-tags"].latest] : undefined;
|
|
140
|
-
const lastPublish = Date.parse(published[0].date);
|
|
141
|
-
const daysSince = Math.round((Date.now() - lastPublish) / 86400000);
|
|
142
|
-
return [
|
|
143
|
-
`Release timeline for ${pkg}`,
|
|
144
|
-
`First published: ${String(time.created ?? "not reported").slice(0, 10)}`,
|
|
145
|
-
`Latest: ${doc["dist-tags"]?.latest ?? "no latest tag"}, ${daysSince} day(s) ago`,
|
|
146
|
-
`Versions published: ${published.length}, median ${onesignalMedian(gaps).toFixed(1)} day(s) apart`,
|
|
147
|
-
latestManifest?.engines?.node ? `Latest requires Node ${latestManifest.engines.node}` : "Node engine on the latest: not reported",
|
|
148
|
-
latestManifest?.peerDependencies ? `Latest peer dependencies: ${Object.entries(latestManifest.peerDependencies).map(([k, v]) => `${k} ${v}`).join(", ")}` : "Peer dependencies on the latest: none declared",
|
|
149
|
-
"",
|
|
150
|
-
`Dist-tags: ${tags.map(([k, v]) => `${k} -> ${v}`).join(", ") || "none reported"}`,
|
|
151
|
-
prereleaseTags.length ? `Prerelease tags still on the board: ${prereleaseTags.map(([k, v]) => `${k}=${v}`).join(", ")}` : "No prerelease tags left on the board.",
|
|
152
|
-
"",
|
|
153
|
-
`Most recent ${Math.min(limit, published.length)} of ${published.length}:`,
|
|
154
|
-
...published.slice(0, limit).map((p) => ` ${p.version} ${p.date.slice(0, 10)}`),
|
|
155
|
-
"",
|
|
156
|
-
"A median gap of a few days with many prerelease tags means a rolling release train; a slow cadence with a single latest tag means a conservative one.",
|
|
157
|
-
].join("\n");
|
|
158
|
-
}
|
|
159
|
-
/** OneSignal's own record of its reliability, per product area, with the incident history behind it. */
|
|
160
|
-
export async function platformStatus(args) {
|
|
161
|
-
const limit = onesignalBound(args?.limit, 1, ONESIGNAL_LIST_CAP, 8);
|
|
162
|
-
const [summary, incidentDoc] = await Promise.all([
|
|
163
|
-
onesignalJson(`${ONESIGNAL_STATUS}/summary.json`),
|
|
164
|
-
onesignalJson(`${ONESIGNAL_STATUS}/incidents.json`),
|
|
165
|
-
]);
|
|
166
|
-
const components = summary.components ?? [];
|
|
167
|
-
const down = components.filter((c) => c.status && c.status !== "operational");
|
|
168
|
-
const incidents = incidentDoc.incidents ?? [];
|
|
169
|
-
const open = incidents.filter((i) => i.status !== "resolved");
|
|
170
|
-
const resolved = incidents
|
|
171
|
-
.filter((i) => i.created_at && i.resolved_at)
|
|
172
|
-
.map((i) => (Date.parse(i.resolved_at) - Date.parse(i.created_at)) / 3600000);
|
|
173
|
-
const byImpact = new Map();
|
|
174
|
-
for (const i of incidents)
|
|
175
|
-
byImpact.set(i.impact ?? "unknown", (byImpact.get(i.impact ?? "unknown") ?? 0) + 1);
|
|
176
|
-
return [
|
|
177
|
-
`OneSignal platform status: ${summary.status?.description ?? "not reported"} (indicator ${summary.status?.indicator ?? "?"})`,
|
|
178
|
-
`Product areas tracked: ${components.length}${down.length ? `, ${down.length} not operational: ${down.map((c) => `${c.name} (${c.status})`).join(", ")}` : ", all operational"}`,
|
|
179
|
-
"",
|
|
180
|
-
components.map((c) => ` ${c.status === "operational" ? "ok " : "!! "}${c.name ?? "?"}${c.description ? ` — ${c.description}` : ""}`).join("\n"),
|
|
181
|
-
"",
|
|
182
|
-
`Incidents on record: ${incidents.length}, ${open.length} unresolved`,
|
|
183
|
-
`By impact: ${[...byImpact.entries()].sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k} ${v}`).join(", ")}`,
|
|
184
|
-
resolved.length ? `Time to resolve: median ${onesignalMedian(resolved).toFixed(1)} hours over ${resolved.length} resolved incidents` : "No resolved incident carries both a start and an end time.",
|
|
185
|
-
"",
|
|
186
|
-
`Most recent ${Math.min(limit, incidents.length)}:`,
|
|
187
|
-
...incidents.slice(0, limit).map((i) => {
|
|
188
|
-
const hours = i.created_at && i.resolved_at ? (Date.parse(i.resolved_at) - Date.parse(i.created_at)) / 3600000 : null;
|
|
189
|
-
const last = i.incident_updates?.[i.incident_updates.length - 1]?.body?.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
190
|
-
return [
|
|
191
|
-
` ${i.status === "resolved" ? "resolved" : "OPEN "} [${i.impact ?? "?"}] ${i.name ?? "(unnamed)"}`,
|
|
192
|
-
` ${String(i.created_at ?? "?").slice(0, 16).replace("T", " ")}${hours === null ? ", no end time" : `, took ${hours.toFixed(1)} hours`}`,
|
|
193
|
-
last ? ` latest: ${last.slice(0, 180)}` : "",
|
|
194
|
-
].filter(Boolean).join("\n");
|
|
195
|
-
}),
|
|
196
|
-
].filter(Boolean).join("\n");
|
|
197
|
-
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin client for the premium gateway.
|
|
3
|
+
*
|
|
4
|
+
* The premium tools do not run in this package any more. Their bodies live on
|
|
5
|
+
* the gateway, which checks a Polar licence key before it does any work, so
|
|
6
|
+
* deleting anything here buys nothing: the answer only exists behind the gate.
|
|
7
|
+
*
|
|
8
|
+
* Set `MCP_LICENSE_KEY` to a Polar licence key. `MCP_GATEWAY_URL` overrides the
|
|
9
|
+
* endpoint, which is only useful for testing.
|
|
10
|
+
*/
|
|
11
|
+
/** Raised when a premium call cannot be completed. `message` is safe to show. */
|
|
12
|
+
export declare class GatewayError extends Error {
|
|
13
|
+
readonly refused: boolean;
|
|
14
|
+
constructor(message: string, refused: boolean);
|
|
15
|
+
}
|
|
16
|
+
export declare function premiumTool(tool: string, args: Record<string, unknown>): Promise<string>;
|
package/dist/gateway.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin client for the premium gateway.
|
|
3
|
+
*
|
|
4
|
+
* The premium tools do not run in this package any more. Their bodies live on
|
|
5
|
+
* the gateway, which checks a Polar licence key before it does any work, so
|
|
6
|
+
* deleting anything here buys nothing: the answer only exists behind the gate.
|
|
7
|
+
*
|
|
8
|
+
* Set `MCP_LICENSE_KEY` to a Polar licence key. `MCP_GATEWAY_URL` overrides the
|
|
9
|
+
* endpoint, which is only useful for testing.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_GATEWAY_URL = 'https://mcp-gateway.contactae2000.workers.dev/mcp';
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 25000;
|
|
13
|
+
const USER_AGENT = 'mrfentmen-mcp/1.0';
|
|
14
|
+
/** Raised when a premium call cannot be completed. `message` is safe to show. */
|
|
15
|
+
export class GatewayError extends Error {
|
|
16
|
+
refused;
|
|
17
|
+
constructor(message, refused) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.refused = refused;
|
|
20
|
+
this.name = 'GatewayError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function premiumTool(tool, args) {
|
|
24
|
+
const key = (process.env.MCP_LICENSE_KEY ?? '').trim();
|
|
25
|
+
if (!key) {
|
|
26
|
+
throw new GatewayError(`"${tool}" is a premium tool and no licence key is set. Set MCP_LICENSE_KEY in your MCP client config.`, true);
|
|
27
|
+
}
|
|
28
|
+
const url = (process.env.MCP_GATEWAY_URL ?? DEFAULT_GATEWAY_URL).trim();
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
31
|
+
let response;
|
|
32
|
+
try {
|
|
33
|
+
response = await fetch(url, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: {
|
|
36
|
+
'content-type': 'application/json',
|
|
37
|
+
accept: 'application/json',
|
|
38
|
+
'user-agent': USER_AGENT,
|
|
39
|
+
authorization: `Bearer ${key}`,
|
|
40
|
+
},
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
jsonrpc: '2.0',
|
|
43
|
+
id: 1,
|
|
44
|
+
method: 'tools/call',
|
|
45
|
+
params: { name: tool, arguments: args },
|
|
46
|
+
}),
|
|
47
|
+
signal: controller.signal,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new GatewayError(`"${tool}" could not reach the licence gateway. Check your connection and try again.`, false);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
}
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
throw new GatewayError(`"${tool}" could not reach the licence gateway (HTTP ${response.status}).`, false);
|
|
58
|
+
}
|
|
59
|
+
let body;
|
|
60
|
+
try {
|
|
61
|
+
body = (await response.json());
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new GatewayError(`"${tool}" got an unreadable answer from the licence gateway.`, false);
|
|
65
|
+
}
|
|
66
|
+
if (body.error) {
|
|
67
|
+
throw new GatewayError(`"${tool}" was refused by the licence gateway: ${body.error.message ?? 'unknown error'}`, false);
|
|
68
|
+
}
|
|
69
|
+
const result = body.result ?? {};
|
|
70
|
+
const text = (result.content ?? [])
|
|
71
|
+
.map((part) => part.text ?? '')
|
|
72
|
+
.join('\n')
|
|
73
|
+
.trim();
|
|
74
|
+
if (result.isError) {
|
|
75
|
+
// The gateway marks a licence refusal structurally, so a missing or rejected
|
|
76
|
+
// key is told apart from a tool that failed after the gate let it through.
|
|
77
|
+
if (result.structuredContent?.licenceRefusal) {
|
|
78
|
+
throw new GatewayError(text || `"${tool}" needs a valid licence key.`, true);
|
|
79
|
+
}
|
|
80
|
+
throw new GatewayError(text || `"${tool}" failed on the licence gateway.`, false);
|
|
81
|
+
}
|
|
82
|
+
if (!text)
|
|
83
|
+
throw new GatewayError(`"${tool}" returned no data.`, false);
|
|
84
|
+
return text;
|
|
85
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { errorMessage, listApps, getApp, listNotifications, pushSdkPopularity
|
|
4
|
-
import {
|
|
3
|
+
import { errorMessage, listApps, getApp, listNotifications, pushSdkPopularity } from "./api.js";
|
|
4
|
+
import { premiumTool } from "./gateway.js";
|
|
5
5
|
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
6
6
|
const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
7
7
|
const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
|
|
@@ -77,12 +77,10 @@ export function createServer() {
|
|
|
77
77
|
annotations: READ_ONLY,
|
|
78
78
|
}, async (args) => {
|
|
79
79
|
// ---- paywall: sdk_release_timeline ----
|
|
80
|
-
|
|
81
|
-
if (paywallMessage)
|
|
82
|
-
return { content: [{ type: "text", text: paywallMessage }], isError: true };
|
|
80
|
+
// The body runs on the gateway, behind the licence gate.
|
|
83
81
|
// ---- paywall: end ----
|
|
84
82
|
try {
|
|
85
|
-
return text(await
|
|
83
|
+
return text(await premiumTool("mcp_onesignal_sdk_release_timeline", args));
|
|
86
84
|
}
|
|
87
85
|
catch (e) {
|
|
88
86
|
return textError(errorMessage(e));
|
|
@@ -95,12 +93,10 @@ export function createServer() {
|
|
|
95
93
|
annotations: READ_ONLY,
|
|
96
94
|
}, async (args) => {
|
|
97
95
|
// ---- paywall: platform_status ----
|
|
98
|
-
|
|
99
|
-
if (paywallMessage)
|
|
100
|
-
return { content: [{ type: "text", text: paywallMessage }], isError: true };
|
|
96
|
+
// The body runs on the gateway, behind the licence gate.
|
|
101
97
|
// ---- paywall: end ----
|
|
102
98
|
try {
|
|
103
|
-
return text(await
|
|
99
|
+
return text(await premiumTool("mcp_onesignal_platform_status", args));
|
|
104
100
|
}
|
|
105
101
|
catch (e) {
|
|
106
102
|
return textError(errorMessage(e));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "onesignal-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Query the OneSignal API (free tier; use a current v2 Organization API key for ONESIGNAL_USER_KEY and the app REST API key for ONESIGNAL_APP_KEY, Basic auth).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"mcpName": "io.github.mrfentmen/onesignal-mcp",
|
package/dist/license.js
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
// Premium tools on this server need a license key bought from MCP Marketplace.
|
|
2
|
-
// Buyers put the key in their MCP client config as MCP_LICENSE_KEY. Every tool
|
|
3
|
-
// that is not listed in PREMIUM keeps working without a key.
|
|
4
|
-
const SLUG = "onesignal-mcp";
|
|
5
|
-
// Tools that need a key. Everything else is free.
|
|
6
|
-
const PREMIUM = new Set([
|
|
7
|
-
"sdk_release_timeline",
|
|
8
|
-
"platform_status",
|
|
9
|
-
]);
|
|
10
|
-
const BUY_URL = `https://mcp-marketplace.io/server/io-github-mrfentmen-${SLUG}`;
|
|
11
|
-
// The license check calls the marketplace's verify endpoint directly instead of
|
|
12
|
-
// using @mcp_marketplace/license. That SDK sends no `apikey` header, so every
|
|
13
|
-
// check it makes is rejected by Supabase before it reaches the function. The
|
|
14
|
-
// publishable key below is public by design - it ships in mcp-marketplace.io's
|
|
15
|
-
// own JavaScript and only ever reaches their licence check.
|
|
16
|
-
const DEFAULT_VERIFY_URL = "https://virupvwhtkpkjsiskckg.supabase.co/functions/v1/verify-key";
|
|
17
|
-
const PUBLISHABLE_KEY = "sb_publishable_BuqlW96Ke8C_zzJG-LQv1Q_YHi_r_4h";
|
|
18
|
-
const OK_CACHE_MS = 60 * 60 * 1000;
|
|
19
|
-
const BAD_CACHE_MS = 60 * 1000;
|
|
20
|
-
const seen = new Map();
|
|
21
|
-
const REASONS = {
|
|
22
|
-
missing_key: "no key is set",
|
|
23
|
-
invalid_format: "that key is not a valid MCP Marketplace key",
|
|
24
|
-
not_found: "that key was not found",
|
|
25
|
-
revoked: "that key has been revoked",
|
|
26
|
-
rotated: "that key was rotated, use your new one",
|
|
27
|
-
expired: "that key has expired, renew it",
|
|
28
|
-
rate_limited: "there were too many checks just now, try again shortly",
|
|
29
|
-
network_error: "the license server could not be reached",
|
|
30
|
-
};
|
|
31
|
-
function blocked(tool, reason) {
|
|
32
|
-
const why = REASONS[reason] ?? `the license server answered "${reason}"`;
|
|
33
|
-
return `"${tool}" needs a license key, but ${why}. ` +
|
|
34
|
-
`Set MCP_LICENSE_KEY in your MCP client config. Get a key: ${BUY_URL}`;
|
|
35
|
-
}
|
|
36
|
-
async function verify(key) {
|
|
37
|
-
const res = await fetch(process.env.MCP_LICENSE_VERIFY_URL || DEFAULT_VERIFY_URL, {
|
|
38
|
-
method: "POST",
|
|
39
|
-
headers: {
|
|
40
|
-
apikey: PUBLISHABLE_KEY,
|
|
41
|
-
Authorization: `Bearer ${PUBLISHABLE_KEY}`,
|
|
42
|
-
"Content-Type": "application/json",
|
|
43
|
-
},
|
|
44
|
-
body: JSON.stringify({ key, slug: SLUG }),
|
|
45
|
-
// an unusable key answers 400 with a JSON body, so read the body either way
|
|
46
|
-
signal: AbortSignal.timeout(15000),
|
|
47
|
-
});
|
|
48
|
-
const data = (await res.json());
|
|
49
|
-
if (typeof data?.valid !== "boolean")
|
|
50
|
-
return { valid: false, reason: "unexpected_response" };
|
|
51
|
-
return data;
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Returns null when the caller may run the tool, or a short message telling
|
|
55
|
-
* them how to get a key.
|
|
56
|
-
*
|
|
57
|
-
* A good key is remembered for an hour, so a busy session does not hit the
|
|
58
|
-
* license server on every call and keeps working through a brief outage.
|
|
59
|
-
*/
|
|
60
|
-
export async function premiumRequired(tool) {
|
|
61
|
-
if (!PREMIUM.has(tool))
|
|
62
|
-
return null;
|
|
63
|
-
const key = process.env.MCP_LICENSE_KEY;
|
|
64
|
-
if (!key)
|
|
65
|
-
return blocked(tool, "missing_key");
|
|
66
|
-
const hit = seen.get(key);
|
|
67
|
-
if (hit && Date.now() - hit.at < (hit.valid ? OK_CACHE_MS : BAD_CACHE_MS)) {
|
|
68
|
-
return hit.valid ? null : blocked(tool, hit.reason ?? "invalid");
|
|
69
|
-
}
|
|
70
|
-
let result;
|
|
71
|
-
try {
|
|
72
|
-
result = await verify(key);
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
result = { valid: false, reason: "network_error" };
|
|
76
|
-
}
|
|
77
|
-
seen.set(key, { ...result, at: Date.now() });
|
|
78
|
-
return result.valid ? null : blocked(tool, result.reason ?? "invalid");
|
|
79
|
-
}
|