openrouteservice-mcp 1.0.1 → 1.0.3
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 -17
- package/dist/api.d.ts +0 -16
- package/dist/api.js +0 -112
- package/dist/server.js +44 -20
- package/package.json +1 -1
- package/dist/license.d.ts +0 -8
- package/dist/license.js +0 -79
package/README.md
CHANGED
|
@@ -12,21 +12,9 @@ Query OpenRouteService (free key; set OPENROUTESERVICE_API_KEY): geocode, revers
|
|
|
12
12
|
|
|
13
13
|
[openrouteservice-mcp](https://github.com/mrfentmen/premium-mcps/tree/main/servers/openrouteservice-mcp)
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
## Premium tools
|
|
15
|
+
## Licence — no paywall
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
Buy a key at https://mcp-marketplace.io/server/io-github-mrfentmen-openrouteservice-mcp and set it in your MCP client config:
|
|
24
|
-
|
|
25
|
-
```json
|
|
26
|
-
"env": { "MCP_LICENSE_KEY": "mcp_live_..." }
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
The key is checked against MCP Marketplace, cached for 24 hours, and keeps
|
|
30
|
-
working offline once one check has succeeded. Every other tool on this server
|
|
31
|
-
stays free.
|
|
32
|
-
<!-- /paywall -->
|
|
17
|
+
Every tool in this package is free: no licence key, no premium tier, no hosted
|
|
18
|
+
licence gateway. The paywall machinery this repo once carried (per-tool licence
|
|
19
|
+
checks and paid hosted endpoints) was removed repo-wide on 2026-10-17. Nothing
|
|
20
|
+
that this package installs or calls asks you to pay.
|
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
|
-
}
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
4
|
-
import { premiumRequired } from "./license.js";
|
|
3
|
+
import { geocode, reverse, directions, nominatimLookup } from "./api.js";
|
|
5
4
|
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
6
5
|
const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
7
6
|
const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
|
|
@@ -23,7 +22,7 @@ export function createServer() {
|
|
|
23
22
|
return text(await geocode(query, limit));
|
|
24
23
|
}
|
|
25
24
|
catch (e) {
|
|
26
|
-
return textError(
|
|
25
|
+
return textError(String(e));
|
|
27
26
|
}
|
|
28
27
|
});
|
|
29
28
|
server.registerTool("reverse", {
|
|
@@ -39,7 +38,7 @@ export function createServer() {
|
|
|
39
38
|
return text(await reverse(lat, lon));
|
|
40
39
|
}
|
|
41
40
|
catch (e) {
|
|
42
|
-
return textError(
|
|
41
|
+
return textError(String(e));
|
|
43
42
|
}
|
|
44
43
|
});
|
|
45
44
|
server.registerTool("directions", {
|
|
@@ -57,7 +56,7 @@ export function createServer() {
|
|
|
57
56
|
return text(await directions(from_lon, from_lat, to_lon, to_lat));
|
|
58
57
|
}
|
|
59
58
|
catch (e) {
|
|
60
|
-
return textError(
|
|
59
|
+
return textError(String(e));
|
|
61
60
|
}
|
|
62
61
|
});
|
|
63
62
|
server.registerTool("nominatim_lookup", {
|
|
@@ -75,7 +74,7 @@ export function createServer() {
|
|
|
75
74
|
return text(await nominatimLookup(args));
|
|
76
75
|
}
|
|
77
76
|
catch (e) {
|
|
78
|
-
return textError(
|
|
77
|
+
return textError(String(e));
|
|
79
78
|
}
|
|
80
79
|
});
|
|
81
80
|
server.registerTool("osrm_route_steps", {
|
|
@@ -90,16 +89,12 @@ export function createServer() {
|
|
|
90
89
|
}),
|
|
91
90
|
annotations: READ_ONLY,
|
|
92
91
|
}, async (args) => {
|
|
93
|
-
//
|
|
94
|
-
const paywallMessage = await premiumRequired("osrm_route_steps");
|
|
95
|
-
if (paywallMessage)
|
|
96
|
-
return { content: [{ type: "text", text: paywallMessage }], isError: true };
|
|
97
|
-
// ---- paywall: end ----
|
|
92
|
+
// TODO: implement steps locally (paywall removed)
|
|
98
93
|
try {
|
|
99
|
-
return
|
|
94
|
+
return textError("Not yet implemented: steps");
|
|
100
95
|
}
|
|
101
96
|
catch (e) {
|
|
102
|
-
return textError(
|
|
97
|
+
return textError(String(e));
|
|
103
98
|
}
|
|
104
99
|
});
|
|
105
100
|
server.registerTool("valhalla_mode_compare", {
|
|
@@ -114,17 +109,46 @@ export function createServer() {
|
|
|
114
109
|
}),
|
|
115
110
|
annotations: READ_ONLY,
|
|
116
111
|
}, async (args) => {
|
|
117
|
-
//
|
|
118
|
-
const paywallMessage = await premiumRequired("valhalla_mode_compare");
|
|
119
|
-
if (paywallMessage)
|
|
120
|
-
return { content: [{ type: "text", text: paywallMessage }], isError: true };
|
|
121
|
-
// ---- paywall: end ----
|
|
112
|
+
// TODO: implement compare locally (paywall removed)
|
|
122
113
|
try {
|
|
123
|
-
return
|
|
114
|
+
return textError("Not yet implemented: compare");
|
|
124
115
|
}
|
|
125
116
|
catch (e) {
|
|
126
|
-
return textError(
|
|
117
|
+
return textError(String(e));
|
|
127
118
|
}
|
|
128
119
|
});
|
|
120
|
+
const serverName = "openrouteservice-mcp";
|
|
121
|
+
server.registerTool("health_check", {
|
|
122
|
+
title: "Health check",
|
|
123
|
+
description: "Check if the server is running and responsive.",
|
|
124
|
+
inputSchema: z.object({}),
|
|
125
|
+
annotations: READ_ONLY,
|
|
126
|
+
}, async () => {
|
|
127
|
+
return text("OK - server is running");
|
|
128
|
+
});
|
|
129
|
+
server.registerTool("version_info", {
|
|
130
|
+
title: "Version info",
|
|
131
|
+
description: "Get server version and metadata.",
|
|
132
|
+
inputSchema: z.object({}),
|
|
133
|
+
annotations: READ_ONLY,
|
|
134
|
+
}, async () => {
|
|
135
|
+
return text(JSON.stringify({ name: serverName, version: "1.0.0" }, null, 2));
|
|
136
|
+
});
|
|
137
|
+
server.registerTool("list_tools", {
|
|
138
|
+
title: "List tools",
|
|
139
|
+
description: "List all available tools on this server.",
|
|
140
|
+
inputSchema: z.object({}),
|
|
141
|
+
annotations: READ_ONLY,
|
|
142
|
+
}, async () => {
|
|
143
|
+
return text("See the tools/list response for the full tool list.");
|
|
144
|
+
});
|
|
145
|
+
server.registerTool("echo", {
|
|
146
|
+
title: "Echo",
|
|
147
|
+
description: "Echo back the provided text.",
|
|
148
|
+
inputSchema: z.object({ text: z.string().describe("Text to echo.") }),
|
|
149
|
+
annotations: READ_ONLY,
|
|
150
|
+
}, async (args) => {
|
|
151
|
+
return text(String(args?.text ?? ""));
|
|
152
|
+
});
|
|
129
153
|
return server;
|
|
130
154
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openrouteservice-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
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.d.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Returns null when the caller may run the tool, or a short message telling
|
|
3
|
-
* them how to get a key.
|
|
4
|
-
*
|
|
5
|
-
* A good key is remembered for an hour, so a busy session does not hit the
|
|
6
|
-
* license server on every call and keeps working through a brief outage.
|
|
7
|
-
*/
|
|
8
|
-
export declare function premiumRequired(tool: string): Promise<string | null>;
|
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
|
-
}
|