openrouteservice-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 -16
- package/dist/api.js +0 -112
- 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 OpenRouteService (free key; set OPENROUTESERVICE_API_KEY): geocode, revers
|
|
|
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
|
* `osrm_route_steps`
|
|
21
21
|
* `valhalla_mode_compare`
|
|
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
|
@@ -12,19 +12,3 @@ export declare function nominatimLookup(args: {
|
|
|
12
12
|
lon?: number;
|
|
13
13
|
limit?: number;
|
|
14
14
|
}): Promise<string>;
|
|
15
|
-
/** A road route from OSRM, with the turn-by-turn an OpenStreetMap router can give. */
|
|
16
|
-
export declare function osrmRouteSteps(args: {
|
|
17
|
-
from_lat?: number;
|
|
18
|
-
from_lon?: number;
|
|
19
|
-
to_lat?: number;
|
|
20
|
-
to_lon?: number;
|
|
21
|
-
alternatives?: boolean;
|
|
22
|
-
}): Promise<string>;
|
|
23
|
-
/** The same trip by several modes, from Valhalla, which models them with different cost functions. */
|
|
24
|
-
export declare function valhallaModeCompare(args: {
|
|
25
|
-
from_lat?: number;
|
|
26
|
-
from_lon?: number;
|
|
27
|
-
to_lat?: number;
|
|
28
|
-
to_lon?: number;
|
|
29
|
-
modes?: string;
|
|
30
|
-
}): Promise<string>;
|
package/dist/api.js
CHANGED
|
@@ -147,115 +147,3 @@ export async function nominatimLookup(args) {
|
|
|
147
147
|
"This is a different geocoder from the one this server's other tools use, and they will disagree: Nominatim ranks by OpenStreetMap importance, while a commercial geocoder ranks by its own address database. A pin that moves between them is usually a boundary or a duplicated feature, not a bug in either.",
|
|
148
148
|
].filter(Boolean).join("\n");
|
|
149
149
|
}
|
|
150
|
-
/** A road route from OSRM, with the turn-by-turn an OpenStreetMap router can give. */
|
|
151
|
-
export async function osrmRouteSteps(args) {
|
|
152
|
-
const nums = [args.from_lat, args.from_lon, args.to_lat, args.to_lon].map((v) => Number(v));
|
|
153
|
-
if (nums.some((n) => !Number.isFinite(n))) {
|
|
154
|
-
throw new OrsError("All four of from_lat, from_lon, to_lat and to_lon are required, for example 48.8584, 2.2945 to 48.8606, 2.3376.");
|
|
155
|
-
}
|
|
156
|
-
const [fromLat, fromLon, toLat, toLon] = nums;
|
|
157
|
-
if (Math.abs(fromLat) > 90 || Math.abs(toLat) > 90)
|
|
158
|
-
throw new OrsError("from_lat and to_lat must be between -90 and 90.");
|
|
159
|
-
if (Math.abs(fromLon) > 180 || Math.abs(toLon) > 180)
|
|
160
|
-
throw new OrsError("from_lon and to_lon must be between -180 and 180.");
|
|
161
|
-
// This OSRM build rejects any geometries parameter, including "none"; overview=false
|
|
162
|
-
// already suppresses the route geometry, and per-step geometry is not printed.
|
|
163
|
-
const params = new URLSearchParams({ overview: "false", steps: "true" });
|
|
164
|
-
if (args.alternatives !== false)
|
|
165
|
-
params.set("alternatives", "3");
|
|
166
|
-
const d = await orsFetch(`${ORS_OSRM}/route/v1/driving/${fromLon},${fromLat};${toLon},${toLat}?${params}`);
|
|
167
|
-
if (d.code !== "Ok" || !d.routes?.length)
|
|
168
|
-
throw new OrsError(`OSRM could not route between those points (status ${d.code ?? "unknown"}). Check that both points are on the road network.`);
|
|
169
|
-
const routes = d.routes;
|
|
170
|
-
return [
|
|
171
|
-
`OSRM road route, ${routes.length} option(s) found (OpenStreetMap road data)`,
|
|
172
|
-
`The demo OSRM instance is shared and not meant for production traffic, so treat the route as an indication rather than a navigation instruction.`,
|
|
173
|
-
"",
|
|
174
|
-
...routes.map((r, ri) => {
|
|
175
|
-
const steps = r.legs?.flatMap((l) => l.steps ?? []) ?? [];
|
|
176
|
-
const named = steps.filter((s) => s.name && s.distance > 20);
|
|
177
|
-
return [
|
|
178
|
-
`Option ${ri + 1}: ${orsMetres(r.distance)}, ${orsDuration(r.duration)} estimated${ri === 0 ? " (the one OSRM prefers)" : ""}`,
|
|
179
|
-
` via ${named.length} named road segment(s): ${[...new Set(named.map((s) => s.name))].slice(0, 8).join(", ") || "none recorded"}`,
|
|
180
|
-
` turn-by-turn:`,
|
|
181
|
-
...steps.slice(0, 10).map((s) => {
|
|
182
|
-
const m = s.maneuver ?? {};
|
|
183
|
-
const what = [m.modifier, m.type].filter(Boolean).join(" ");
|
|
184
|
-
return ` ${String(Math.round(s.distance)).padStart(6)} m ${s.name || "(unnamed road)"} — ${what}${m.instruction ? `: ${m.instruction}` : ""}`;
|
|
185
|
-
}),
|
|
186
|
-
steps.length > 10 ? ` ... ${steps.length - 10} further step(s) not shown` : "",
|
|
187
|
-
].filter(Boolean).join("\n");
|
|
188
|
-
}),
|
|
189
|
-
"",
|
|
190
|
-
"The duration is a free-flow estimate with no traffic, no turn penalties beyond the model's own, and no time-of-day variation, so it is optimistic compared to what a navigation app will show you.",
|
|
191
|
-
].join("\n");
|
|
192
|
-
}
|
|
193
|
-
/** The same trip by several modes, from Valhalla, which models them with different cost functions. */
|
|
194
|
-
export async function valhallaModeCompare(args) {
|
|
195
|
-
const nums = [args.from_lat, args.from_lon, args.to_lat, args.to_lon].map((v) => Number(v));
|
|
196
|
-
if (nums.some((n) => !Number.isFinite(n))) {
|
|
197
|
-
throw new OrsError("All four of from_lat, from_lon, to_lat and to_lon are required.");
|
|
198
|
-
}
|
|
199
|
-
const [fromLat, fromLon, toLat, toLon] = nums;
|
|
200
|
-
const known = ["auto", "bicycle", "pedestrian", "multimodal", "truck", "taxi"];
|
|
201
|
-
const wanted = String(args?.modes ?? "auto,bicycle,pedestrian")
|
|
202
|
-
.split(",")
|
|
203
|
-
.map((m) => m.trim().toLowerCase())
|
|
204
|
-
.filter(Boolean)
|
|
205
|
-
.filter((m) => known.includes(m))
|
|
206
|
-
.slice(0, 4);
|
|
207
|
-
if (!wanted.length) {
|
|
208
|
-
throw new OrsError(`No usable mode in "${args.modes}". Valhalla costings here: ${known.join(", ")}.`);
|
|
209
|
-
}
|
|
210
|
-
const results = [];
|
|
211
|
-
for (const mode of wanted) {
|
|
212
|
-
try {
|
|
213
|
-
const d = await orsFetch(`${ORS_VALHALLA}/route?json=`, {
|
|
214
|
-
method: "POST",
|
|
215
|
-
headers: { "Content-Type": "application/json" },
|
|
216
|
-
body: JSON.stringify({
|
|
217
|
-
locations: [{ lat: fromLat, lon: fromLon }, { lat: toLat, lon: toLon }],
|
|
218
|
-
costing: mode,
|
|
219
|
-
directions_options: { units: "kilometers" },
|
|
220
|
-
...(mode === "multimodal" ? { costings: [{ costing: "pedestrian" }, { costing: "bicycle" }] } : {}),
|
|
221
|
-
}),
|
|
222
|
-
});
|
|
223
|
-
if (d.error) {
|
|
224
|
-
results.push({ mode, ok: false, note: `${d.error_code ?? "error"}: ${d.error}` });
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
const s = d.trip?.summary ?? {};
|
|
228
|
-
const maneuvers = d.trip?.legs?.flatMap((l) => l.maneuvers ?? []) ?? [];
|
|
229
|
-
results.push({
|
|
230
|
-
mode,
|
|
231
|
-
ok: true,
|
|
232
|
-
note: [
|
|
233
|
-
orsMetres((s.length ?? 0) * 1000),
|
|
234
|
-
orsDuration(s.time ?? 0),
|
|
235
|
-
`cost ${s.cost ?? "not reported"}`,
|
|
236
|
-
s.has_toll ? "tolls" : "no tolls",
|
|
237
|
-
s.has_ferry ? "includes a ferry" : "",
|
|
238
|
-
s.has_highway ? "uses a motorway" : "",
|
|
239
|
-
s.has_time_restrictions ? "has a time restriction" : "",
|
|
240
|
-
s.energy_use?.value !== undefined ? `energy ${s.energy_use.value} ${s.energy_use.unit ?? ""}` : "",
|
|
241
|
-
`${maneuvers.length} maneuver(s)`,
|
|
242
|
-
].filter(Boolean).join(", "),
|
|
243
|
-
});
|
|
244
|
-
if (maneuvers.length) {
|
|
245
|
-
results[results.length - 1].note += `\n first turns: ${maneuvers.slice(0, 3).map((m) => `${m.instruction ?? "?"}${m.street_names?.length ? ` (${m.street_names[0]})` : ""}`).join(" | ")}`;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
catch (e) {
|
|
249
|
-
results.push({ mode, ok: false, note: errorMessage(e) });
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
return [
|
|
253
|
-
`Same trip by ${wanted.length} costing model(s), from Valhalla's public instance`,
|
|
254
|
-
`${fromLat}, ${fromLon} to ${toLat}, ${toLon}`,
|
|
255
|
-
"",
|
|
256
|
-
...results.map((r) => ` ${r.ok ? "ok " : "fail"} ${r.mode.padEnd(11)} ${r.note}`),
|
|
257
|
-
"",
|
|
258
|
-
"The costing models disagree on purpose: a bicycle and a pedestrian route on the same two points are different journeys, and the fast car route is often the one you cannot walk. Compare them before assuming one mode is the right one.",
|
|
259
|
-
"Valhalla's public instance is a shared demo, so a routing failure here is not evidence the trip is unroutable.",
|
|
260
|
-
].join("\n");
|
|
261
|
-
}
|
|
@@ -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, geocode, reverse, directions, nominatimLookup
|
|
4
|
-
import {
|
|
3
|
+
import { errorMessage, geocode, reverse, directions, nominatimLookup } 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 };
|
|
@@ -91,12 +91,10 @@ export function createServer() {
|
|
|
91
91
|
annotations: READ_ONLY,
|
|
92
92
|
}, async (args) => {
|
|
93
93
|
// ---- paywall: osrm_route_steps ----
|
|
94
|
-
|
|
95
|
-
if (paywallMessage)
|
|
96
|
-
return { content: [{ type: "text", text: paywallMessage }], isError: true };
|
|
94
|
+
// The body runs on the gateway, behind the licence gate.
|
|
97
95
|
// ---- paywall: end ----
|
|
98
96
|
try {
|
|
99
|
-
return text(await
|
|
97
|
+
return text(await premiumTool("mcp_openrouteservice_osrm_route_steps", args));
|
|
100
98
|
}
|
|
101
99
|
catch (e) {
|
|
102
100
|
return textError(errorMessage(e));
|
|
@@ -115,12 +113,10 @@ export function createServer() {
|
|
|
115
113
|
annotations: READ_ONLY,
|
|
116
114
|
}, async (args) => {
|
|
117
115
|
// ---- paywall: valhalla_mode_compare ----
|
|
118
|
-
|
|
119
|
-
if (paywallMessage)
|
|
120
|
-
return { content: [{ type: "text", text: paywallMessage }], isError: true };
|
|
116
|
+
// The body runs on the gateway, behind the licence gate.
|
|
121
117
|
// ---- paywall: end ----
|
|
122
118
|
try {
|
|
123
|
-
return text(await
|
|
119
|
+
return text(await premiumTool("mcp_openrouteservice_valhalla_mode_compare", args));
|
|
124
120
|
}
|
|
125
121
|
catch (e) {
|
|
126
122
|
return textError(errorMessage(e));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openrouteservice-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Query OpenRouteService (free key; set OPENROUTESERVICE_API_KEY): geocode, reverse geocode, driving directions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"mcpName": "io.github.mrfentmen/openrouteservice-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 = "openrouteservice-mcp";
|
|
5
|
-
// Tools that need a key. Everything else is free.
|
|
6
|
-
const PREMIUM = new Set([
|
|
7
|
-
"osrm_route_steps",
|
|
8
|
-
"valhalla_mode_compare",
|
|
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
|
-
}
|